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.
| Fact | Value |
|---|---|
| Tool | V0 |
| Build time | 1-2 hours |
| Difficulty | Intermediate |
| Last updated | April 2026 |
Project overview
Tech stack
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
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.
1-- Dashboards2create 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);1213-- Data sources14create 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);2223-- Widgets24create 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);3637-- Snapshots for historical data38create 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);4445-- CSV data storage46create 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);5253-- RLS54alter 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;5960create 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 all63 using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));64create policy "Owners manage widgets" on widgets for all65 using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));66create policy "Public widget read" on widgets for select67 using (dashboard_id in (select id from dashboards where is_public = true));68create policy "Owners manage snapshots" on snapshots for all69 using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));70create policy "Owners manage data rows" on data_rows for all71 using (data_source_id in (72 select ds.id from data_sources ds73 join dashboards d on ds.dashboard_id = d.id74 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.
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.
1// app/dashboards/[id]/page.tsx2'use client';34import { 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';1314const ResponsiveGrid = WidthProvider(Responsive);1516type 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};2425export 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);3031 useEffect(() => {32 async function load() {33 const { data: dashboard } = await supabase34 .from('dashboards')35 .select('title')36 .eq('id', params.id)37 .single();38 if (dashboard) setTitle(dashboard.title);3940 const { data } = await supabase41 .from('widgets')42 .select('*')43 .eq('dashboard_id', params.id);44 if (data) setWidgets(data);45 }46 load();47 }, [params.id]);4849 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 }));5859 const handleLayoutChange = useCallback(60 (newLayout: any[]) => {61 // Debounce save to prevent excessive writes62 if (saveTimeout) clearTimeout(saveTimeout);6364 const timeout = setTimeout(async () => {65 for (const item of newLayout) {66 await supabase67 .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);7475 setSaveTimeout(timeout);7677 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 );8990 async function addWidget() {91 const { data } = await supabase92 .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 }104105 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 Widget112 </Button>113 <Button variant="outline">114 <Share2 className="h-4 w-4 mr-2" /> Share115 </Button>116 </div>117 </div>118119 <ResponsiveGrid120 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 isDraggable126 isResizable127 >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.
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.
1// components/chart-widget.tsx2'use client';34import { 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';1112const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];1314type Widget = {15 id: string;16 type: string;17 title: string;18 config: any;19 data_source_id: string | null;20};2122export function ChartWidget({ widget }: { widget: Widget }) {23 const [data, setData] = useState<any[]>([]);24 const supabase = createClient();2526 useEffect(() => {27 async function fetchData() {28 if (!widget.data_source_id) {29 // Demo data for unconfigured widgets30 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 }3738 const { data: source } = await supabase39 .from('data_sources')40 .select('type, config')41 .eq('id', widget.data_source_id)42 .single();4344 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 supabase50 .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]);5960 const xKey = widget.config?.xKey || 'name';61 const yKey = widget.config?.yKey || 'value';62 const color = widget.config?.color || COLORS[0];6364 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 }7576 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 }100101 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.
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.
1// app/api/data-sources/csv/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { createClient } from '@/lib/supabase/server';4import Papa from 'papaparse';56export 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 });1011 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;1516 if (!file || !dashboardId) {17 return NextResponse.json({ error: 'Missing file or dashboardId' }, { status: 400 });18 }1920 // Parse CSV21 const text = await file.text();22 const parsed = Papa.parse(text, {23 header: true,24 skipEmptyLines: true,25 dynamicTyping: true,26 });2728 if (parsed.errors.length > 0) {29 return NextResponse.json({ error: 'CSV parse error', details: parsed.errors }, { status: 400 });30 }3132 // Create data source33 const { data: source, error: sourceError } = await supabase34 .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();4748 if (sourceError || !source) {49 return NextResponse.json({ error: 'Failed to create data source' }, { status: 500 });50 }5152 // Insert parsed rows in batches of 10053 const rows = parsed.data as Record<string, any>[];54 const batchSize = 100;5556 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 }));6162 await supabase.from('data_rows').insert(batch);63 }6465 return NextResponse.json({66 id: source.id,67 name: source.name,68 columns: parsed.meta.fields,69 rowCount: rows.length,70 });71}7273// components/csv-upload.tsx74'use client';7576import { 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';8182export 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);9293 async function handleUpload() {94 const file = fileRef.current?.files?.[0];95 if (!file) return;9697 setUploading(true);98 const formData = new FormData();99 formData.append('file', file);100 formData.append('dashboardId', dashboardId);101 formData.append('name', name || file.name);102103 const res = await fetch('/api/data-sources/csv', {104 method: 'POST',105 body: formData,106 });107108 const data = await res.json();109 if (res.ok) onUpload(data);110 setUploading(false);111 }112113 return (114 <Card>115 <CardHeader>116 <CardTitle className="text-sm flex items-center gap-2">117 <FileSpreadsheet className="h-4 w-4" /> Upload CSV118 </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.
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.
1// components/widget-config.tsx2'use client';34import { 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';1213const CHART_TYPES = ['line', 'bar', 'pie', 'area', 'scatter', 'number', 'table'];14const PRESET_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];1516type Widget = {17 id: string;18 type: string;19 title: string;20 config: any;21 data_source_id: string | null;22};2324type DataSource = {25 id: string;26 name: string;27 config: { columns?: string[] };28};2930export 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);4748 const selectedSource = dataSources.find(s => s.id === sourceId);49 const columns = selectedSource?.config?.columns || [];5051 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 };5960 await supabase.from('widgets').update({61 title, type,62 data_source_id: sourceId || null,63 config: updated.config,64 }).eq('id', widget.id);6566 onUpdate(updated);67 setOpen(false);68 }6970 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)} />8384 <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>9293 <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>101102 {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 )}118119 <div className="flex gap-2">120 {PRESET_COLORS.map(c => (121 <button122 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>129130 <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
1'use client';23import { 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';1011const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];1213type Widget = { id: string; type: string; title: string; config: any; data_source_id: string | null };1415export function ChartWidget({ widget }: { widget: Widget }) {16 const [data, setData] = useState<any[]>([]);17 const supabase = createClient();1819 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]);3637 const xKey = widget.config?.xKey || 'name';38 const yKey = widget.config?.yKey || 'value';39 const color = widget.config?.color || COLORS[0];4041 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 }4546 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 }4950 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation