# How to Build Data Visualization Tools with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or Free tier, Supabase free tier
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 1. Set Up the Supabase Schema for Dashboards and Widgets

*Storing widget positions and chart configurations as JSONB columns gives maximum flexibility for the drag-and-resize grid without requiring schema changes for each new chart type.*

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.

```
-- Dashboards
create table dashboards (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid references auth.users not null,
  title text not null,
  description text,
  layout jsonb default '[]',
  is_public boolean default false,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Data sources
create table data_sources (
  id uuid primary key default gen_random_uuid(),
  dashboard_id uuid references dashboards on delete cascade not null,
  name text not null,
  type text check (type in ('supabase','csv','api')) not null,
  config jsonb default '{}',
  created_at timestamptz default now()
);

-- Widgets
create table widgets (
  id uuid primary key default gen_random_uuid(),
  dashboard_id uuid references dashboards on delete cascade not null,
  data_source_id uuid references data_sources,
  type text check (type in ('line','bar','pie','area','scatter','number','table')) not null,
  title text not null,
  query text,
  config jsonb default '{}',
  position jsonb default '{"x": 0, "y": 0, "w": 4, "h": 3}',
  size jsonb default '{"minW": 2, "minH": 2}',
  created_at timestamptz default now()
);

-- Snapshots for historical data
create table snapshots (
  id uuid primary key default gen_random_uuid(),
  dashboard_id uuid references dashboards on delete cascade not null,
  data jsonb not null,
  captured_at timestamptz default now()
);

-- CSV data storage
create table data_rows (
  id uuid primary key default gen_random_uuid(),
  data_source_id uuid references data_sources on delete cascade not null,
  row_data jsonb not null,
  created_at timestamptz default now()
);

-- RLS
alter table dashboards enable row level security;
alter table data_sources enable row level security;
alter table widgets enable row level security;
alter table snapshots enable row level security;
alter table data_rows enable row level security;

create policy "Owners manage dashboards" on dashboards for all using (auth.uid() = owner_id);
create policy "Public dashboards readable" on dashboards for select using (is_public = true);
create policy "Owners manage sources" on data_sources for all
  using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));
create policy "Owners manage widgets" on widgets for all
  using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));
create policy "Public widget read" on widgets for select
  using (dashboard_id in (select id from dashboards where is_public = true));
create policy "Owners manage snapshots" on snapshots for all
  using (dashboard_id in (select id from dashboards where owner_id = auth.uid()));
create policy "Owners manage data rows" on data_rows for all
  using (data_source_id in (
    select ds.id from data_sources ds
    join dashboards d on ds.dashboard_id = d.id
    where d.owner_id = auth.uid()
  ));
```

**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

*react-grid-layout provides a battle-tested drag-and-resize system with responsive breakpoints. Debouncing layout saves prevents excessive database writes during continuous dragging.*

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.

```
// app/dashboards/[id]/page.tsx
'use client';

import { useState, useCallback, useEffect } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import { createClient } from '@/lib/supabase/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ChartWidget } from '@/components/chart-widget';
import { Plus, Share2, Save } from 'lucide-react';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';

const ResponsiveGrid = WidthProvider(Responsive);

type Widget = {
  id: string;
  type: string;
  title: string;
  position: { x: number; y: number; w: number; h: number };
  config: any;
  data_source_id: string | null;
};

export default function DashboardPage({ params }: { params: { id: string } }) {
  const supabase = createClient();
  const [widgets, setWidgets] = useState<Widget[]>([]);
  const [title, setTitle] = useState('');
  const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);

  useEffect(() => {
    async function load() {
      const { data: dashboard } = await supabase
        .from('dashboards')
        .select('title')
        .eq('id', params.id)
        .single();
      if (dashboard) setTitle(dashboard.title);

      const { data } = await supabase
        .from('widgets')
        .select('*')
        .eq('dashboard_id', params.id);
      if (data) setWidgets(data);
    }
    load();
  }, [params.id]);

  const layout = widgets.map((w) => ({
    i: w.id,
    x: w.position.x,
    y: w.position.y,
    w: w.position.w,
    h: w.position.h,
    minW: 2,
    minH: 2,
  }));

  const handleLayoutChange = useCallback(
    (newLayout: any[]) => {
      // Debounce save to prevent excessive writes
      if (saveTimeout) clearTimeout(saveTimeout);

      const timeout = setTimeout(async () => {
        for (const item of newLayout) {
          await supabase
            .from('widgets')
            .update({
              position: { x: item.x, y: item.y, w: item.w, h: item.h },
            })
            .eq('id', item.i);
        }
      }, 500);

      setSaveTimeout(timeout);

      setWidgets((prev) =>
        prev.map((w) => {
          const item = newLayout.find((l) => l.i === w.id);
          if (item) {
            return { ...w, position: { x: item.x, y: item.y, w: item.w, h: item.h } };
          }
          return w;
        })
      );
    },
    [saveTimeout]
  );

  async function addWidget() {
    const { data } = await supabase
      .from('widgets')
      .insert({
        dashboard_id: params.id,
        type: 'bar',
        title: 'New Chart',
        position: { x: 0, y: Infinity, w: 4, h: 3 },
        config: {},
      })
      .select()
      .single();
    if (data) setWidgets([...widgets, data]);
  }

  return (
    <div className="max-w-7xl mx-auto p-6">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">{title}</h1>
        <div className="flex gap-2">
          <Button variant="outline" onClick={addWidget}>
            <Plus className="h-4 w-4 mr-2" /> Add Widget
          </Button>
          <Button variant="outline">
            <Share2 className="h-4 w-4 mr-2" /> Share
          </Button>
        </div>
      </div>

      <ResponsiveGrid
        layouts={{ lg: layout }}
        breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480 }}
        cols={{ lg: 12, md: 10, sm: 6, xs: 4 }}
        rowHeight={100}
        onLayoutChange={handleLayoutChange}
        isDraggable
        isResizable
      >
        {widgets.map((widget) => (
          <div key={widget.id}>
            <Card className="h-full">
              <CardHeader className="pb-2">
                <CardTitle className="text-sm">{widget.title}</CardTitle>
              </CardHeader>
              <CardContent className="h-[calc(100%-3rem)]">
                <ChartWidget widget={widget} />
              </CardContent>
            </Card>
          </div>
        ))}
      </ResponsiveGrid>
    </div>
  );
}
```

**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

*A single ChartWidget component that switches on the widget type keeps the code DRY while supporting all six chart types. Recharts' responsive container automatically fits charts to their grid cell size.*

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.

```
// components/chart-widget.tsx
'use client';

import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import {
  LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
  AreaChart, Area, ScatterChart, Scatter,
  XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';

const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];

type Widget = {
  id: string;
  type: string;
  title: string;
  config: any;
  data_source_id: string | null;
};

export function ChartWidget({ widget }: { widget: Widget }) {
  const [data, setData] = useState<any[]>([]);
  const supabase = createClient();

  useEffect(() => {
    async function fetchData() {
      if (!widget.data_source_id) {
        // Demo data for unconfigured widgets
        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 },
        ]);
        return;
      }

      const { data: source } = await supabase
        .from('data_sources')
        .select('type, config')
        .eq('id', widget.data_source_id)
        .single();

      if (source?.type === 'supabase') {
        const tableName = source.config.table;
        const { data: rows } = await supabase.from(tableName).select('*').limit(100);
        if (rows) setData(rows);
      } else if (source?.type === 'csv') {
        const { data: rows } = await supabase
          .from('data_rows')
          .select('row_data')
          .eq('data_source_id', widget.data_source_id)
          .limit(500);
        if (rows) setData(rows.map(r => r.row_data));
      }
    }
    fetchData();
  }, [widget.data_source_id]);

  const xKey = widget.config?.xKey || 'name';
  const yKey = widget.config?.yKey || 'value';
  const color = widget.config?.color || COLORS[0];

  if (widget.type === 'number') {
    const total = data.reduce((sum, d) => sum + (Number(d[yKey]) || 0), 0);
    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>
    );
  }

  if (widget.type === 'table') {
    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>
    );
  }

  return (
    <ResponsiveContainer width="100%" height="100%">
      {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>
      ) : 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>
      ) : 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>
      ) : 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>
      ) : 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>
      ) : null}
    </ResponsiveContainer>
  );
}
```

**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

*CSV uploads let users bring their own data without database configuration. Parsing on the server via an API route keeps large files from blocking the browser.*

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.

```
// app/api/data-sources/csv/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import Papa from 'papaparse';

export async function POST(request: NextRequest) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const formData = await request.formData();
  const file = formData.get('file') as File;
  const dashboardId = formData.get('dashboardId') as string;
  const name = formData.get('name') as string;

  if (!file || !dashboardId) {
    return NextResponse.json({ error: 'Missing file or dashboardId' }, { status: 400 });
  }

  // Parse CSV
  const text = await file.text();
  const parsed = Papa.parse(text, {
    header: true,
    skipEmptyLines: true,
    dynamicTyping: true,
  });

  if (parsed.errors.length > 0) {
    return NextResponse.json({ error: 'CSV parse error', details: parsed.errors }, { status: 400 });
  }

  // Create data source
  const { data: source, error: sourceError } = await supabase
    .from('data_sources')
    .insert({
      dashboard_id: dashboardId,
      name: name || file.name,
      type: 'csv',
      config: {
        columns: parsed.meta.fields,
        rowCount: parsed.data.length,
        fileName: file.name,
      },
    })
    .select()
    .single();

  if (sourceError || !source) {
    return NextResponse.json({ error: 'Failed to create data source' }, { status: 500 });
  }

  // Insert parsed rows in batches of 100
  const rows = parsed.data as Record<string, any>[];
  const batchSize = 100;

  for (let i = 0; i < rows.length; i += batchSize) {
    const batch = rows.slice(i, i + batchSize).map((row) => ({
      data_source_id: source.id,
      row_data: row,
    }));

    await supabase.from('data_rows').insert(batch);
  }

  return NextResponse.json({
    id: source.id,
    name: source.name,
    columns: parsed.meta.fields,
    rowCount: rows.length,
  });
}

// components/csv-upload.tsx
'use client';

import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Upload, FileSpreadsheet } from 'lucide-react';

export function CsvUpload({
  dashboardId,
  onUpload,
}: {
  dashboardId: string;
  onUpload: (source: any) => void;
}) {
  const [uploading, setUploading] = useState(false);
  const [name, setName] = useState('');
  const fileRef = useRef<HTMLInputElement>(null);

  async function handleUpload() {
    const file = fileRef.current?.files?.[0];
    if (!file) return;

    setUploading(true);
    const formData = new FormData();
    formData.append('file', file);
    formData.append('dashboardId', dashboardId);
    formData.append('name', name || file.name);

    const res = await fetch('/api/data-sources/csv', {
      method: 'POST',
      body: formData,
    });

    const data = await res.json();
    if (res.ok) onUpload(data);
    setUploading(false);
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm flex items-center gap-2">
          <FileSpreadsheet className="h-4 w-4" /> Upload CSV
        </CardTitle>
      </CardHeader>
      <CardContent className="space-y-3">
        <Input placeholder="Data source name" value={name} onChange={e => setName(e.target.value)} />
        <Input ref={fileRef} type="file" accept=".csv" />
        <Button onClick={handleUpload} disabled={uploading} className="w-full">
          <Upload className="h-4 w-4 mr-2" />
          {uploading ? 'Uploading...' : 'Upload & Parse'}
        </Button>
      </CardContent>
    </Card>
  );
}
```

**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

*The configuration dialog lets users customize each chart's data mapping and appearance. Public sharing generates a read-only URL for stakeholders without requiring them to log in.*

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.

```
// components/widget-config.tsx
'use client';

import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Settings } from 'lucide-react';

const CHART_TYPES = ['line', 'bar', 'pie', 'area', 'scatter', 'number', 'table'];
const PRESET_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];

type Widget = {
  id: string;
  type: string;
  title: string;
  config: any;
  data_source_id: string | null;
};

type DataSource = {
  id: string;
  name: string;
  config: { columns?: string[] };
};

export function WidgetConfig({
  widget,
  dataSources,
  onUpdate,
}: {
  widget: Widget;
  dataSources: DataSource[];
  onUpdate: (updated: Widget) => void;
}) {
  const supabase = createClient();
  const [title, setTitle] = useState(widget.title);
  const [type, setType] = useState(widget.type);
  const [sourceId, setSourceId] = useState(widget.data_source_id || '');
  const [xKey, setXKey] = useState(widget.config?.xKey || 'name');
  const [yKey, setYKey] = useState(widget.config?.yKey || 'value');
  const [color, setColor] = useState(widget.config?.color || '#3B82F6');
  const [open, setOpen] = useState(false);

  const selectedSource = dataSources.find(s => s.id === sourceId);
  const columns = selectedSource?.config?.columns || [];

  async function handleSave() {
    const updated = {
      ...widget,
      title,
      type,
      data_source_id: sourceId || null,
      config: { ...widget.config, xKey, yKey, color },
    };

    await supabase.from('widgets').update({
      title, type,
      data_source_id: sourceId || null,
      config: updated.config,
    }).eq('id', widget.id);

    onUpdate(updated);
    setOpen(false);
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="ghost" size="icon" className="h-6 w-6">
          <Settings className="h-3 w-3" />
        </Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Configure Widget</DialogTitle>
        </DialogHeader>
        <div className="space-y-4">
          <Input placeholder="Widget title" value={title} onChange={e => setTitle(e.target.value)} />

          <Select value={type} onValueChange={setType}>
            <SelectTrigger><SelectValue placeholder="Chart type" /></SelectTrigger>
            <SelectContent>
              {CHART_TYPES.map(t => (
                <SelectItem key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</SelectItem>
              ))}
            </SelectContent>
          </Select>

          <Select value={sourceId} onValueChange={setSourceId}>
            <SelectTrigger><SelectValue placeholder="Data source" /></SelectTrigger>
            <SelectContent>
              {dataSources.map(s => (
                <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
              ))}
            </SelectContent>
          </Select>

          {columns.length > 0 && (
            <div className="grid grid-cols-2 gap-2">
              <Select value={xKey} onValueChange={setXKey}>
                <SelectTrigger><SelectValue placeholder="X-axis" /></SelectTrigger>
                <SelectContent>
                  {columns.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
                </SelectContent>
              </Select>
              <Select value={yKey} onValueChange={setYKey}>
                <SelectTrigger><SelectValue placeholder="Y-axis" /></SelectTrigger>
                <SelectContent>
                  {columns.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
                </SelectContent>
              </Select>
            </div>
          )}

          <div className="flex gap-2">
            {PRESET_COLORS.map(c => (
              <button
                key={c}
                className={`h-8 w-8 rounded-full border-2 ${color === c ? 'border-primary' : 'border-transparent'}`}
                style={{ backgroundColor: c }}
                onClick={() => setColor(c)}
              />
            ))}
          </div>

          <Button onClick={handleSave} className="w-full">Save Configuration</Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

**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 example

File: `components/chart-widget.tsx`

```typescript
'use client';

import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import {
  LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
  AreaChart, Area, ScatterChart, Scatter,
  XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';

const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];

type Widget = { id: string; type: string; title: string; config: any; data_source_id: string | null };

export function ChartWidget({ widget }: { widget: Widget }) {
  const [data, setData] = useState<any[]>([]);
  const supabase = createClient();

  useEffect(() => {
    async function fetchData() {
      if (!widget.data_source_id) {
        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 }]);
        return;
      }
      const { data: source } = await supabase.from('data_sources').select('type, config').eq('id', widget.data_source_id).single();
      if (source?.type === 'supabase') {
        const { data: rows } = await supabase.from(source.config.table).select('*').limit(100);
        if (rows) setData(rows);
      } else if (source?.type === 'csv') {
        const { data: rows } = await supabase.from('data_rows').select('row_data').eq('data_source_id', widget.data_source_id).limit(500);
        if (rows) setData(rows.map(r => r.row_data));
      }
    }
    fetchData();
  }, [widget.data_source_id]);

  const xKey = widget.config?.xKey || 'name';
  const yKey = widget.config?.yKey || 'value';
  const color = widget.config?.color || COLORS[0];

  if (widget.type === 'number') {
    const total = data.reduce((sum, d) => sum + (Number(d[yKey]) || 0), 0);
    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>);
  }

  if (widget.type === 'table') {
    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>);
  }

  return (
    <ResponsiveContainer width="100%" height="100%">
      {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>
      ) : 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>
      ) : 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>
      ) : 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>
      ) : 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>
      ) : null}
    </ResponsiveContainer>
  );
}
```

## Common mistakes

- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined

## 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/data-visualizations-tools
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/data-visualizations-tools
