Skip to main content
RapidDev - Software Development Agency

How to Build Data Visualization Tools with V0

Build interactive data visualization dashboards where users create customizable charts, connect to data sources, and share visual dashboards with filtering. V0 generates the Next.js pages while Recharts renders line, bar, pie, area, and scatter charts. The drag-and-resize grid layout uses react-grid-layout with widget positions saved to Supabase.

What you'll build

  • Dashboard builder with drag-and-resize widget grid using react-grid-layout
  • Six chart types: line, bar, pie, area, scatter, and number cards via Recharts
  • Data source connections for Supabase tables, CSV uploads, and external APIs
  • Widget configuration dialog for chart type, data source, query, and styling
  • Public read-only dashboard sharing with a unique URL
  • Dashboard snapshots for point-in-time data preservation
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate17 min read1-2 hoursV0 Premium or Free tier, Supabase free tierLast updated April 2026RapidDev Engineering Team
TL;DR

Build interactive data visualization dashboards where users create customizable charts, connect to data sources, and share visual dashboards with filtering. V0 generates the Next.js pages while Recharts renders line, bar, pie, area, and scatter charts. The drag-and-resize grid layout uses react-grid-layout with widget positions saved to Supabase.

Quick facts about this guide
FactValue
ToolV0
Build time1-2 hours
DifficultyIntermediate
Last updatedApril 2026

Project overview

Tech stack

V0 by Vercel (AI code generation)
Next.js 14+ (App Router)
TypeScript
Tailwind CSS + shadcn/ui
Supabase (PostgreSQL + Auth + Storage)
Recharts (charts)
react-grid-layout (drag-resize)

Prerequisites

  • A V0 account (free or Premium)
  • A Supabase project with Auth configured
  • Basic familiarity with React charts and TypeScript
  • Understanding of JSON data structures for chart configuration

Build steps

1

Set Up the Supabase Schema for Dashboards and Widgets

Create tables for dashboards, data sources, widgets, and snapshots. Widget position (x, y, w, h) and chart configuration are stored as JSONB for flexibility. Enable RLS so dashboard owners control access.

typescript
1-- Dashboards
2create table dashboards (
3 id uuid primary key default gen_random_uuid(),
4 owner_id uuid references auth.users not null,
5 title text not null,
6 description text,
7 layout jsonb default '[]',
8 is_public boolean default false,
9 created_at timestamptz default now(),
10 updated_at timestamptz default now()
11);
12
13-- Data sources
14create table data_sources (
15 id uuid primary key default gen_random_uuid(),
16 dashboard_id uuid references dashboards on delete cascade not null,
17 name text not null,
18 type text check (type in ('supabase','csv','api')) not null,
19 config jsonb default '{}',
20 created_at timestamptz default now()
21);
22
23-- Widgets
24create table widgets (
25 id uuid primary key default gen_random_uuid(),
26 dashboard_id uuid references dashboards on delete cascade not null,
27 data_source_id uuid references data_sources,
28 type text check (type in ('line','bar','pie','area','scatter','number','table')) not null,
29 title text not null,
30 query text,
31 config jsonb default '{}',
32 position jsonb default '{"x": 0, "y": 0, "w": 4, "h": 3}',
33 size jsonb default '{"minW": 2, "minH": 2}',
34 created_at timestamptz default now()
35);
36
37-- Snapshots for historical data
38create table snapshots (
39 id uuid primary key default gen_random_uuid(),
40 dashboard_id uuid references dashboards on delete cascade not null,
41 data jsonb not null,
42 captured_at timestamptz default now()
43);
44
45-- CSV data storage
46create table data_rows (
47 id uuid primary key default gen_random_uuid(),
48 data_source_id uuid references data_sources on delete cascade not null,
49 row_data jsonb not null,
50 created_at timestamptz default now()
51);
52
53-- RLS
54alter table dashboards enable row level security;
55alter table data_sources enable row level security;
56alter table widgets enable row level security;
57alter table snapshots enable row level security;
58alter table data_rows enable row level security;
59
60create policy "Owners manage dashboards" on dashboards for all using (auth.uid() = owner_id);
61create policy "Public dashboards readable" on dashboards for select using (is_public = true);
62create policy "Owners manage sources" on data_sources for all
63 using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));
64create policy "Owners manage widgets" on widgets for all
65 using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));
66create policy "Public widget read" on widgets for select
67 using (dashboard_id in (select id from dashboards where is_public = true));
68create policy "Owners manage snapshots" on snapshots for all
69 using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));
70create policy "Owners manage data rows" on data_rows for all
71 using (data_source_id in (
72 select ds.id from data_sources ds
73 join dashboards d on ds.dashboard_id = d.id
74 where d.owner_id = auth.uid()
75 ));

Expected result: Database tables created with JSONB columns for flexible widget positioning and chart configuration. RLS enables owner-only access with public read for shared dashboards.

2

Build the Dashboard View with Drag-and-Resize Grid

Create the interactive dashboard page using react-grid-layout for the widget grid. Each widget renders a Recharts chart based on its type. Layout changes are debounced and saved via Server Action. Use Design Mode (Option+D) to fine-tune spacing and colors.

typescript
1// app/dashboards/[id]/page.tsx
2'use client';
3
4import { useState, useCallback, useEffect } from 'react';
5import { Responsive, WidthProvider } from 'react-grid-layout';
6import { createClient } from '@/lib/supabase/client';
7import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8import { Button } from '@/components/ui/button';
9import { ChartWidget } from '@/components/chart-widget';
10import { Plus, Share2, Save } from 'lucide-react';
11import 'react-grid-layout/css/styles.css';
12import 'react-resizable/css/styles.css';
13
14const ResponsiveGrid = WidthProvider(Responsive);
15
16type Widget = {
17 id: string;
18 type: string;
19 title: string;
20 position: { x: number; y: number; w: number; h: number };
21 config: any;
22 data_source_id: string | null;
23};
24
25export default function DashboardPage({ params }: { params: { id: string } }) {
26 const supabase = createClient();
27 const [widgets, setWidgets] = useState<Widget[]>([]);
28 const [title, setTitle] = useState('');
29 const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
30
31 useEffect(() => {
32 async function load() {
33 const { data: dashboard } = await supabase
34 .from('dashboards')
35 .select('title')
36 .eq('id', params.id)
37 .single();
38 if (dashboard) setTitle(dashboard.title);
39
40 const { data } = await supabase
41 .from('widgets')
42 .select('*')
43 .eq('dashboard_id', params.id);
44 if (data) setWidgets(data);
45 }
46 load();
47 }, [params.id]);
48
49 const layout = widgets.map((w) => ({
50 i: w.id,
51 x: w.position.x,
52 y: w.position.y,
53 w: w.position.w,
54 h: w.position.h,
55 minW: 2,
56 minH: 2,
57 }));
58
59 const handleLayoutChange = useCallback(
60 (newLayout: any[]) => {
61 // Debounce save to prevent excessive writes
62 if (saveTimeout) clearTimeout(saveTimeout);
63
64 const timeout = setTimeout(async () => {
65 for (const item of newLayout) {
66 await supabase
67 .from('widgets')
68 .update({
69 position: { x: item.x, y: item.y, w: item.w, h: item.h },
70 })
71 .eq('id', item.i);
72 }
73 }, 500);
74
75 setSaveTimeout(timeout);
76
77 setWidgets((prev) =>
78 prev.map((w) => {
79 const item = newLayout.find((l) => l.i === w.id);
80 if (item) {
81 return { ...w, position: { x: item.x, y: item.y, w: item.w, h: item.h } };
82 }
83 return w;
84 })
85 );
86 },
87 [saveTimeout]
88 );
89
90 async function addWidget() {
91 const { data } = await supabase
92 .from('widgets')
93 .insert({
94 dashboard_id: params.id,
95 type: 'bar',
96 title: 'New Chart',
97 position: { x: 0, y: Infinity, w: 4, h: 3 },
98 config: {},
99 })
100 .select()
101 .single();
102 if (data) setWidgets([...widgets, data]);
103 }
104
105 return (
106 <div className="max-w-7xl mx-auto p-6">
107 <div className="flex items-center justify-between mb-6">
108 <h1 className="text-2xl font-bold">{title}</h1>
109 <div className="flex gap-2">
110 <Button variant="outline" onClick={addWidget}>
111 <Plus className="h-4 w-4 mr-2" /> Add Widget
112 </Button>
113 <Button variant="outline">
114 <Share2 className="h-4 w-4 mr-2" /> Share
115 </Button>
116 </div>
117 </div>
118
119 <ResponsiveGrid
120 layouts={{ lg: layout }}
121 breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480 }}
122 cols={{ lg: 12, md: 10, sm: 6, xs: 4 }}
123 rowHeight={100}
124 onLayoutChange={handleLayoutChange}
125 isDraggable
126 isResizable
127 >
128 {widgets.map((widget) => (
129 <div key={widget.id}>
130 <Card className="h-full">
131 <CardHeader className="pb-2">
132 <CardTitle className="text-sm">{widget.title}</CardTitle>
133 </CardHeader>
134 <CardContent className="h-[calc(100%-3rem)]">
135 <ChartWidget widget={widget} />
136 </CardContent>
137 </Card>
138 </div>
139 ))}
140 </ResponsiveGrid>
141 </div>
142 );
143}

Expected result: An interactive dashboard with draggable and resizable chart widgets on a responsive grid. Layout changes are automatically debounced and saved to Supabase.

3

Create the Chart Widget Renderer with Recharts

Build the ChartWidget component that renders the appropriate Recharts chart based on the widget type. Support line, bar, pie, area, scatter charts and a number card for single-value KPIs.

typescript
1// components/chart-widget.tsx
2'use client';
3
4import { useEffect, useState } from 'react';
5import { createClient } from '@/lib/supabase/client';
6import {
7 LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
8 AreaChart, Area, ScatterChart, Scatter,
9 XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
10} from 'recharts';
11
12const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
13
14type Widget = {
15 id: string;
16 type: string;
17 title: string;
18 config: any;
19 data_source_id: string | null;
20};
21
22export function ChartWidget({ widget }: { widget: Widget }) {
23 const [data, setData] = useState<any[]>([]);
24 const supabase = createClient();
25
26 useEffect(() => {
27 async function fetchData() {
28 if (!widget.data_source_id) {
29 // Demo data for unconfigured widgets
30 setData([
31 { name: 'Jan', value: 400 }, { name: 'Feb', value: 300 },
32 { name: 'Mar', value: 600 }, { name: 'Apr', value: 200 },
33 { name: 'May', value: 500 }, { name: 'Jun', value: 350 },
34 ]);
35 return;
36 }
37
38 const { data: source } = await supabase
39 .from('data_sources')
40 .select('type, config')
41 .eq('id', widget.data_source_id)
42 .single();
43
44 if (source?.type === 'supabase') {
45 const tableName = source.config.table;
46 const { data: rows } = await supabase.from(tableName).select('*').limit(100);
47 if (rows) setData(rows);
48 } else if (source?.type === 'csv') {
49 const { data: rows } = await supabase
50 .from('data_rows')
51 .select('row_data')
52 .eq('data_source_id', widget.data_source_id)
53 .limit(500);
54 if (rows) setData(rows.map(r => r.row_data));
55 }
56 }
57 fetchData();
58 }, [widget.data_source_id]);
59
60 const xKey = widget.config?.xKey || 'name';
61 const yKey = widget.config?.yKey || 'value';
62 const color = widget.config?.color || COLORS[0];
63
64 if (widget.type === 'number') {
65 const total = data.reduce((sum, d) => sum + (Number(d[yKey]) || 0), 0);
66 return (
67 <div className="flex items-center justify-center h-full">
68 <div className="text-center">
69 <p className="text-4xl font-bold">{total.toLocaleString()}</p>
70 <p className="text-sm text-muted-foreground mt-1">{widget.config?.label || 'Total'}</p>
71 </div>
72 </div>
73 );
74 }
75
76 if (widget.type === 'table') {
77 return (
78 <div className="overflow-auto h-full text-sm">
79 <table className="w-full">
80 <thead>
81 <tr>
82 {data[0] && Object.keys(data[0]).map(key => (
83 <th key={key} className="text-left p-1 border-b font-medium">{key}</th>
84 ))}
85 </tr>
86 </thead>
87 <tbody>
88 {data.slice(0, 20).map((row, i) => (
89 <tr key={i}>
90 {Object.values(row).map((val, j) => (
91 <td key={j} className="p-1 border-b">{String(val)}</td>
92 ))}
93 </tr>
94 ))}
95 </tbody>
96 </table>
97 </div>
98 );
99 }
100
101 return (
102 <ResponsiveContainer width="100%" height="100%">
103 {widget.type === 'line' ? (
104 <LineChart data={data}>
105 <CartesianGrid strokeDasharray="3 3" />
106 <XAxis dataKey={xKey} tick={{ fontSize: 12 }} />
107 <YAxis tick={{ fontSize: 12 }} />
108 <Tooltip />
109 <Line type="monotone" dataKey={yKey} stroke={color} strokeWidth={2} dot={false} />
110 </LineChart>
111 ) : widget.type === 'bar' ? (
112 <BarChart data={data}>
113 <CartesianGrid strokeDasharray="3 3" />
114 <XAxis dataKey={xKey} tick={{ fontSize: 12 }} />
115 <YAxis tick={{ fontSize: 12 }} />
116 <Tooltip />
117 <Bar dataKey={yKey} fill={color} radius={[4, 4, 0, 0]} />
118 </BarChart>
119 ) : widget.type === 'area' ? (
120 <AreaChart data={data}>
121 <CartesianGrid strokeDasharray="3 3" />
122 <XAxis dataKey={xKey} tick={{ fontSize: 12 }} />
123 <YAxis tick={{ fontSize: 12 }} />
124 <Tooltip />
125 <Area type="monotone" dataKey={yKey} fill={color} fillOpacity={0.2} stroke={color} />
126 </AreaChart>
127 ) : widget.type === 'scatter' ? (
128 <ScatterChart>
129 <CartesianGrid strokeDasharray="3 3" />
130 <XAxis dataKey={xKey} tick={{ fontSize: 12 }} />
131 <YAxis dataKey={yKey} tick={{ fontSize: 12 }} />
132 <Tooltip />
133 <Scatter data={data} fill={color} />
134 </ScatterChart>
135 ) : widget.type === 'pie' ? (
136 <PieChart>
137 <Pie data={data} dataKey={yKey} nameKey={xKey} cx="50%" cy="50%" outerRadius="80%">
138 {data.map((_, i) => (
139 <Cell key={i} fill={COLORS[i % COLORS.length]} />
140 ))}
141 </Pie>
142 <Tooltip />
143 <Legend />
144 </PieChart>
145 ) : null}
146 </ResponsiveContainer>
147 );
148}

Expected result: A flexible chart widget component that renders line, bar, pie, area, scatter charts, number cards, and data tables based on the widget type configuration.

4

Add CSV Upload Data Source with Papaparse

Create an API route that receives CSV file uploads, parses them with papaparse, and stores the structured rows in the data_rows table. Use Supabase Storage for the raw file and papaparse for server-side parsing.

typescript
1// app/api/data-sources/csv/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { createClient } from '@/lib/supabase/server';
4import Papa from 'papaparse';
5
6export async function POST(request: NextRequest) {
7 const supabase = await createClient();
8 const { data: { user } } = await supabase.auth.getUser();
9 if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
10
11 const formData = await request.formData();
12 const file = formData.get('file') as File;
13 const dashboardId = formData.get('dashboardId') as string;
14 const name = formData.get('name') as string;
15
16 if (!file || !dashboardId) {
17 return NextResponse.json({ error: 'Missing file or dashboardId' }, { status: 400 });
18 }
19
20 // Parse CSV
21 const text = await file.text();
22 const parsed = Papa.parse(text, {
23 header: true,
24 skipEmptyLines: true,
25 dynamicTyping: true,
26 });
27
28 if (parsed.errors.length > 0) {
29 return NextResponse.json({ error: 'CSV parse error', details: parsed.errors }, { status: 400 });
30 }
31
32 // Create data source
33 const { data: source, error: sourceError } = await supabase
34 .from('data_sources')
35 .insert({
36 dashboard_id: dashboardId,
37 name: name || file.name,
38 type: 'csv',
39 config: {
40 columns: parsed.meta.fields,
41 rowCount: parsed.data.length,
42 fileName: file.name,
43 },
44 })
45 .select()
46 .single();
47
48 if (sourceError || !source) {
49 return NextResponse.json({ error: 'Failed to create data source' }, { status: 500 });
50 }
51
52 // Insert parsed rows in batches of 100
53 const rows = parsed.data as Record<string, any>[];
54 const batchSize = 100;
55
56 for (let i = 0; i < rows.length; i += batchSize) {
57 const batch = rows.slice(i, i + batchSize).map((row) => ({
58 data_source_id: source.id,
59 row_data: row,
60 }));
61
62 await supabase.from('data_rows').insert(batch);
63 }
64
65 return NextResponse.json({
66 id: source.id,
67 name: source.name,
68 columns: parsed.meta.fields,
69 rowCount: rows.length,
70 });
71}
72
73// components/csv-upload.tsx
74'use client';
75
76import { useState, useRef } from 'react';
77import { Button } from '@/components/ui/button';
78import { Input } from '@/components/ui/input';
79import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
80import { Upload, FileSpreadsheet } from 'lucide-react';
81
82export function CsvUpload({
83 dashboardId,
84 onUpload,
85}: {
86 dashboardId: string;
87 onUpload: (source: any) => void;
88}) {
89 const [uploading, setUploading] = useState(false);
90 const [name, setName] = useState('');
91 const fileRef = useRef<HTMLInputElement>(null);
92
93 async function handleUpload() {
94 const file = fileRef.current?.files?.[0];
95 if (!file) return;
96
97 setUploading(true);
98 const formData = new FormData();
99 formData.append('file', file);
100 formData.append('dashboardId', dashboardId);
101 formData.append('name', name || file.name);
102
103 const res = await fetch('/api/data-sources/csv', {
104 method: 'POST',
105 body: formData,
106 });
107
108 const data = await res.json();
109 if (res.ok) onUpload(data);
110 setUploading(false);
111 }
112
113 return (
114 <Card>
115 <CardHeader>
116 <CardTitle className="text-sm flex items-center gap-2">
117 <FileSpreadsheet className="h-4 w-4" /> Upload CSV
118 </CardTitle>
119 </CardHeader>
120 <CardContent className="space-y-3">
121 <Input placeholder="Data source name" value={name} onChange={e => setName(e.target.value)} />
122 <Input ref={fileRef} type="file" accept=".csv" />
123 <Button onClick={handleUpload} disabled={uploading} className="w-full">
124 <Upload className="h-4 w-4 mr-2" />
125 {uploading ? 'Uploading...' : 'Upload & Parse'}
126 </Button>
127 </CardContent>
128 </Card>
129 );
130}

Expected result: Users can upload CSV files that are parsed server-side with papaparse and stored as structured JSONB rows in Supabase for chart rendering.

5

Add Widget Configuration and Public Sharing

Build a widget configuration dialog where users select the chart type, data source, x-axis and y-axis keys, and chart color. Add a share toggle that sets the dashboard as public and copies the share URL.

typescript
1// components/widget-config.tsx
2'use client';
3
4import { useState } from 'react';
5import { createClient } from '@/lib/supabase/client';
6import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
7import { Button } from '@/components/ui/button';
8import { Input } from '@/components/ui/input';
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
10import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
11import { Settings } from 'lucide-react';
12
13const CHART_TYPES = ['line', 'bar', 'pie', 'area', 'scatter', 'number', 'table'];
14const PRESET_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
15
16type Widget = {
17 id: string;
18 type: string;
19 title: string;
20 config: any;
21 data_source_id: string | null;
22};
23
24type DataSource = {
25 id: string;
26 name: string;
27 config: { columns?: string[] };
28};
29
30export function WidgetConfig({
31 widget,
32 dataSources,
33 onUpdate,
34}: {
35 widget: Widget;
36 dataSources: DataSource[];
37 onUpdate: (updated: Widget) => void;
38}) {
39 const supabase = createClient();
40 const [title, setTitle] = useState(widget.title);
41 const [type, setType] = useState(widget.type);
42 const [sourceId, setSourceId] = useState(widget.data_source_id || '');
43 const [xKey, setXKey] = useState(widget.config?.xKey || 'name');
44 const [yKey, setYKey] = useState(widget.config?.yKey || 'value');
45 const [color, setColor] = useState(widget.config?.color || '#3B82F6');
46 const [open, setOpen] = useState(false);
47
48 const selectedSource = dataSources.find(s => s.id === sourceId);
49 const columns = selectedSource?.config?.columns || [];
50
51 async function handleSave() {
52 const updated = {
53 ...widget,
54 title,
55 type,
56 data_source_id: sourceId || null,
57 config: { ...widget.config, xKey, yKey, color },
58 };
59
60 await supabase.from('widgets').update({
61 title, type,
62 data_source_id: sourceId || null,
63 config: updated.config,
64 }).eq('id', widget.id);
65
66 onUpdate(updated);
67 setOpen(false);
68 }
69
70 return (
71 <Dialog open={open} onOpenChange={setOpen}>
72 <DialogTrigger asChild>
73 <Button variant="ghost" size="icon" className="h-6 w-6">
74 <Settings className="h-3 w-3" />
75 </Button>
76 </DialogTrigger>
77 <DialogContent>
78 <DialogHeader>
79 <DialogTitle>Configure Widget</DialogTitle>
80 </DialogHeader>
81 <div className="space-y-4">
82 <Input placeholder="Widget title" value={title} onChange={e => setTitle(e.target.value)} />
83
84 <Select value={type} onValueChange={setType}>
85 <SelectTrigger><SelectValue placeholder="Chart type" /></SelectTrigger>
86 <SelectContent>
87 {CHART_TYPES.map(t => (
88 <SelectItem key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</SelectItem>
89 ))}
90 </SelectContent>
91 </Select>
92
93 <Select value={sourceId} onValueChange={setSourceId}>
94 <SelectTrigger><SelectValue placeholder="Data source" /></SelectTrigger>
95 <SelectContent>
96 {dataSources.map(s => (
97 <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
98 ))}
99 </SelectContent>
100 </Select>
101
102 {columns.length > 0 && (
103 <div className="grid grid-cols-2 gap-2">
104 <Select value={xKey} onValueChange={setXKey}>
105 <SelectTrigger><SelectValue placeholder="X-axis" /></SelectTrigger>
106 <SelectContent>
107 {columns.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
108 </SelectContent>
109 </Select>
110 <Select value={yKey} onValueChange={setYKey}>
111 <SelectTrigger><SelectValue placeholder="Y-axis" /></SelectTrigger>
112 <SelectContent>
113 {columns.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
114 </SelectContent>
115 </Select>
116 </div>
117 )}
118
119 <div className="flex gap-2">
120 {PRESET_COLORS.map(c => (
121 <button
122 key={c}
123 className={`h-8 w-8 rounded-full border-2 ${color === c ? 'border-primary' : 'border-transparent'}`}
124 style={{ backgroundColor: c }}
125 onClick={() => setColor(c)}
126 />
127 ))}
128 </div>
129
130 <Button onClick={handleSave} className="w-full">Save Configuration</Button>
131 </div>
132 </DialogContent>
133 </Dialog>
134 );
135}

Expected result: A configuration dialog lets users customize chart type, data source, axis mappings, and colors. Changes save to Supabase and the chart updates immediately.

Complete code

components/chart-widget.tsx
1'use client';
2
3import { useEffect, useState } from 'react';
4import { createClient } from '@/lib/supabase/client';
5import {
6 LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
7 AreaChart, Area, ScatterChart, Scatter,
8 XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
9} from 'recharts';
10
11const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
12
13type Widget = { id: string; type: string; title: string; config: any; data_source_id: string | null };
14
15export function ChartWidget({ widget }: { widget: Widget }) {
16 const [data, setData] = useState<any[]>([]);
17 const supabase = createClient();
18
19 useEffect(() => {
20 async function fetchData() {
21 if (!widget.data_source_id) {
22 setData([{ name: 'Jan', value: 400 }, { name: 'Feb', value: 300 }, { name: 'Mar', value: 600 }, { name: 'Apr', value: 200 }, { name: 'May', value: 500 }, { name: 'Jun', value: 350 }]);
23 return;
24 }
25 const { data: source } = await supabase.from('data_sources').select('type, config').eq('id', widget.data_source_id).single();
26 if (source?.type === 'supabase') {
27 const { data: rows } = await supabase.from(source.config.table).select('*').limit(100);
28 if (rows) setData(rows);
29 } else if (source?.type === 'csv') {
30 const { data: rows } = await supabase.from('data_rows').select('row_data').eq('data_source_id', widget.data_source_id).limit(500);
31 if (rows) setData(rows.map(r => r.row_data));
32 }
33 }
34 fetchData();
35 }, [widget.data_source_id]);
36
37 const xKey = widget.config?.xKey || 'name';
38 const yKey = widget.config?.yKey || 'value';
39 const color = widget.config?.color || COLORS[0];
40
41 if (widget.type === 'number') {
42 const total = data.reduce((sum, d) => sum + (Number(d[yKey]) || 0), 0);
43 return (<div className="flex items-center justify-center h-full"><div className="text-center"><p className="text-4xl font-bold">{total.toLocaleString()}</p><p className="text-sm text-muted-foreground mt-1">{widget.config?.label || 'Total'}</p></div></div>);
44 }
45
46 if (widget.type === 'table') {
47 return (<div className="overflow-auto h-full text-sm"><table className="w-full"><thead><tr>{data[0] && Object.keys(data[0]).map(key => (<th key={key} className="text-left p-1 border-b font-medium">{key}</th>))}</tr></thead><tbody>{data.slice(0, 20).map((row, i) => (<tr key={i}>{Object.values(row).map((val, j) => (<td key={j} className="p-1 border-b">{String(val)}</td>))}</tr>))}</tbody></table></div>);
48 }
49
50 return (
51 <ResponsiveContainer width="100%" height="100%">
52 {widget.type === 'line' ? (<LineChart data={data}><CartesianGrid strokeDasharray="3 3" /><XAxis dataKey={xKey} tick={{ fontSize: 12 }} /><YAxis tick={{ fontSize: 12 }} /><Tooltip /><Line type="monotone" dataKey={yKey} stroke={color} strokeWidth={2} dot={false} /></LineChart>
53 ) : widget.type === 'bar' ? (<BarChart data={data}><CartesianGrid strokeDasharray="3 3" /><XAxis dataKey={xKey} tick={{ fontSize: 12 }} /><YAxis tick={{ fontSize: 12 }} /><Tooltip /><Bar dataKey={yKey} fill={color} radius={[4, 4, 0, 0]} /></BarChart>
54 ) : widget.type === 'area' ? (<AreaChart data={data}><CartesianGrid strokeDasharray="3 3" /><XAxis dataKey={xKey} tick={{ fontSize: 12 }} /><YAxis tick={{ fontSize: 12 }} /><Tooltip /><Area type="monotone" dataKey={yKey} fill={color} fillOpacity={0.2} stroke={color} /></AreaChart>
55 ) : widget.type === 'scatter' ? (<ScatterChart><CartesianGrid strokeDasharray="3 3" /><XAxis dataKey={xKey} tick={{ fontSize: 12 }} /><YAxis dataKey={yKey} tick={{ fontSize: 12 }} /><Tooltip /><Scatter data={data} fill={color} /></ScatterChart>
56 ) : widget.type === 'pie' ? (<PieChart><Pie data={data} dataKey={yKey} nameKey={xKey} cx="50%" cy="50%" outerRadius="80%">{data.map((_, i) => (<Cell key={i} fill={COLORS[i % COLORS.length]} />))}</Pie><Tooltip /><Legend /></PieChart>
57 ) : null}
58 </ResponsiveContainer>
59 );
60}

Customization ideas

Common pitfalls

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Best practices

  • Debounce layout saves on drag end with a 500ms timeout to prevent excessive Supabase writes
  • Use react-grid-layout's responsive breakpoints so dashboards adapt to mobile, tablet, and desktop screens
  • Parse CSV files server-side in API routes with papaparse's dynamicTyping option to auto-detect numbers
  • Use Design Mode (Option+D) to fine-tune chart container styling, spacing, and colors without credits
  • Store widget positions as JSONB for maximum flexibility without schema migrations
  • Set RLS so dashboard owners have full access and public dashboards are read-only for unauthenticated users

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I need a Next.js App Router dashboard with draggable and resizable chart widgets using react-grid-layout. Each widget renders a Recharts chart (line, bar, pie, area, scatter, or number card) based on its type configuration. Widget positions save to Supabase as JSONB. Include a widget configuration dialog with chart type, data source, x/y axis selectors, and color picker. Use shadcn/ui and TypeScript.

Build Prompt

Create a Supabase schema for a data visualization tool with dashboards, data_sources (supabase/csv/api types), widgets (with JSONB position and config columns), and data_rows (for parsed CSV storage). Set RLS so owners manage their dashboards and public dashboards are read-only for anyone.

Frequently asked questions

How does the drag-and-resize grid work?

react-grid-layout manages a grid where each widget has x, y, w, h properties defining its position and size in grid units. Users drag widgets by their header and resize from corners. The onLayoutChange callback fires with updated positions, which are debounced and saved to Supabase. The Responsive wrapper adjusts columns and breakpoints for different screen sizes.

Which chart library does V0 use for data visualization?

V0 generates Recharts components, which is a composable React charting library built on D3. It provides LineChart, BarChart, PieChart, AreaChart, and ScatterChart components. Each chart wraps in a ResponsiveContainer that automatically resizes to fit the widget's grid cell.

How are CSV files processed and stored?

CSV files are uploaded to an API route that parses them server-side with papaparse using dynamicTyping to auto-detect numbers. The parsed rows are stored as JSONB objects in a data_rows table linked to the data source. Column names are stored in the data source config for the axis selector dropdowns. This approach avoids re-parsing on every chart render.

How does public dashboard sharing work?

The dashboards table has an is_public boolean column. When set to true, RLS policies allow unauthenticated SELECT on the dashboard and its widgets. The share URL points to /share/[id] which renders a read-only version of the dashboard without edit controls. Anyone with the link can view the charts.

Why debounce layout saves instead of saving immediately?

Dragging a widget triggers onLayoutChange for every pixel of movement. Without debouncing, this would send hundreds of database updates per second. The 500ms debounce waits until the user stops dragging, then saves the final position in a single update per widget. This dramatically reduces Supabase writes and improves performance.

How do I add a new chart type?

Add the new type to the CHART_TYPES array and the widgets.type CHECK constraint. Then add a new branch in the ChartWidget component's render logic with the appropriate Recharts component. The widget configuration dialog automatically includes the new type in its dropdown since it reads from the CHART_TYPES array.

Can RapidDev help build a custom analytics dashboard?

Yes. RapidDev has helped over 600 businesses build custom data visualization and analytics platforms with real-time updates, custom data connectors, and embedded dashboards. Book a free consultation at RapidDev to scope your project.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.