# How to Build Reporting tool with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a customizable business reporting tool with V0 using Next.js, Supabase, and Recharts that lets users create reports with chart visualizations, configure filters, and export to CSV or PDF. Features a drag-and-drop report builder, scheduled generation, and team sharing — all in about 1-2 hours.

## Before you start

- A V0 account (Premium recommended for the report builder complexity)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Sample business data in Supabase tables (sales, users, orders, etc.)
- Basic understanding of what reports your team needs

## Step-by-step guide

### 1. Set up the project and reporting schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the schema for reports, snapshots, data sources, and sharing. This project uses Supabase as both the report metadata store and the data source.

```
// Paste this prompt into V0's AI chat:
// Build a reporting tool. Create a Supabase schema with:
// 1. reports: id (uuid PK), owner_id (uuid FK), name (text), description (text), query_config (jsonb), chart_type (text check bar/line/pie/table/area), filters (jsonb), is_scheduled (boolean default false), schedule_cron (text nullable), created_at (timestamptz), updated_at (timestamptz)
// 2. report_snapshots: id (uuid PK), report_id (uuid FK), data (jsonb), generated_at (timestamptz), file_url (text nullable)
// 3. data_sources: id (uuid PK), owner_id (uuid FK), name (text), type (text check supabase/api/csv), connection_config (jsonb), created_at (timestamptz)
// 4. shared_reports: id (uuid PK), report_id (uuid FK), shared_with (uuid FK), permission (text check view/edit), created_at (timestamptz)
// RLS: owners can CRUD their own reports. Shared users can read or edit based on permission.
// Generate SQL migration and TypeScript types.
```

> Pro tip: Use V0's Connect panel to provision Supabase where both the report metadata AND the source data live — one connection handles everything.

**Expected result:** Supabase is connected with reports, snapshots, data sources, and sharing tables. RLS policies enforce owner-based and permission-based access control.

### 2. Build the report builder interface

Create the report editor page where users configure their report — selecting data source, dimensions, metrics, filters, chart type, and date range. The builder constructs a query_config object stored in the reports table.

```
// Paste this prompt into V0's AI chat:
// Build a report builder at app/reports/[id]/edit/page.tsx.
// Requirements:
// - Left panel: configuration controls
//   - Input for report name
//   - Select for chart type (bar/line/pie/table/area)
//   - Select for data source table (populated from Supabase information_schema)
//   - Select for dimension field (group by)
//   - Select for metric field (count/sum/avg)
//   - DatePicker (Calendar + Popover) for start and end date range
//   - Additional filter rows: Select for field, Select for operator (equals/greater/less/contains), Input for value. Add/Remove Buttons.
// - Right panel: live chart preview using Recharts
//   - Updates as config changes
//   - Renders the selected chart type with real data from Supabase
// - Top bar: Save Button (Server Action), Export DropdownMenu (CSV/PDF/PNG)
// - Use shadcn/ui Card, Select, Input, Calendar, Popover, Tabs, DropdownMenu
// - 'use client' for the interactive builder, Server Action saveReport()
```

**Expected result:** A two-panel report builder with configuration controls on the left and live Recharts preview on the right. Changing filters or chart type immediately updates the visualization.

### 3. Create the report generation API route

Build the API route that executes a report's query configuration against the data source and returns aggregated data. This constructs Supabase queries programmatically from the stored config — never executing raw SQL from user input.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { report_id } = await req.json()

  const { data: report } = await supabase
    .from('reports')
    .select('query_config, chart_type')
    .eq('id', report_id)
    .single()

  if (!report) {
    return NextResponse.json({ error: 'Report not found' }, { status: 404 })
  }

  const config = report.query_config as {
    table: string
    dimension: string
    metric: string
    metric_type: string
    date_field?: string
    start_date?: string
    end_date?: string
    filters?: Array<{ field: string; operator: string; value: string }>
  }

  let query = supabase.from(config.table).select('*')

  if (config.start_date && config.date_field) {
    query = query.gte(config.date_field, config.start_date)
  }
  if (config.end_date && config.date_field) {
    query = query.lte(config.date_field, config.end_date)
  }

  config.filters?.forEach((f) => {
    switch (f.operator) {
      case 'equals': query = query.eq(f.field, f.value); break
      case 'greater': query = query.gt(f.field, f.value); break
      case 'less': query = query.lt(f.field, f.value); break
      case 'contains': query = query.ilike(f.field, `%${f.value}%`); break
    }
  })

  const { data, error } = await query

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  // Aggregate client-side for flexibility
  const grouped = new Map<string, number>()
  data?.forEach((row: Record<string, unknown>) => {
    const key = String(row[config.dimension] ?? 'Unknown')
    const val = Number(row[config.metric] ?? 0)
    const current = grouped.get(key) ?? 0
    grouped.set(key, config.metric_type === 'count' ? current + 1 : current + val)
  })

  const chartData = Array.from(grouped.entries()).map(([name, value]) => ({
    name,
    value,
  }))

  // Save snapshot
  await supabase.from('report_snapshots').insert({
    report_id,
    data: chartData,
  })

  return NextResponse.json({ data: chartData, chart_type: report.chart_type })
}
```

**Expected result:** POST /api/reports/generate executes the report's query config against Supabase, aggregates data by dimension and metric, saves a snapshot, and returns chart-ready data.

### 4. Add CSV and PDF export functionality

Create an export API route that generates CSV or PDF files from report data. CSV is generated server-side as text, PDF uses @react-pdf/renderer for formatted output.

```
// Paste this prompt into V0's AI chat:
// Build a report export API at app/api/reports/export/route.ts.
// Requirements:
// - Accepts POST with { report_id, format: 'csv' | 'pdf' }
// - For CSV: generate comma-separated text from the latest report_snapshot data, return as downloadable file with Content-Disposition header
// - For PDF: use @react-pdf/renderer to create a PDF document with:
//   - Report title and date range header
//   - Table of the data with styled headers and alternating row colors
//   - Summary statistics (total, average)
//   - Footer with generation timestamp
//   - Return as downloadable PDF with Content-Disposition header
// - Set maxDuration = 30 in the route config for large reports
// - Store the generated file URL in report_snapshots.file_url
// Also add export buttons to the report viewer:
// - DropdownMenu with CSV, PDF, PNG options
// - PNG export uses canvas-to-image on the Recharts chart (client-side)
```

> Pro tip: Set export const maxDuration = 30 in the API route for Vercel serverless — PDF generation can be memory-intensive for large reports.

**Expected result:** Reports can be exported as CSV or PDF files. CSV downloads instantly as formatted text. PDF generates a styled document with tables and summary statistics.

### 5. Build the report library and sharing

Create the main reports page showing all saved reports with quick search, and add sharing functionality so team members can view or edit reports.

```
// Paste this prompt into V0's AI chat:
// Build a report library and sharing:
// 1. Report list at app/reports/page.tsx:
//    - Command search bar for quick report finding
//    - Grid of report Cards showing: name, chart type icon, last generated timestamp, shared count Badge
//    - Each Card links to the report viewer
//    - "New Report" Button to create blank report → redirect to builder
// 2. Report viewer at app/reports/[id]/page.tsx:
//    - Full-width Recharts chart rendering the latest snapshot data
//    - Tabs to switch between Chart view and Table view
//    - DatePicker to re-run the report for a different date range
//    - Export DropdownMenu (CSV/PDF)
//    - Share Button opens Dialog with:
//      - Input for team member email
//      - Select for permission (view/edit)
//      - Table of existing shares with role Badge and remove Button
//      - Server Action shareReport()
// 3. Server Components for data fetching, 'use client' for interactive chart and sharing dialog
```

**Expected result:** A report library with searchable Cards, a report viewer with interactive charts and export options, and a sharing Dialog for team collaboration.

## Complete code example

File: `app/api/reports/generate/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { report_id } = await req.json()

  const { data: report } = await supabase
    .from('reports')
    .select('query_config, chart_type')
    .eq('id', report_id)
    .single()

  if (!report) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  const config = report.query_config as {
    table: string
    dimension: string
    metric: string
    metric_type: string
    date_field?: string
    start_date?: string
    end_date?: string
  }

  let query = supabase.from(config.table).select('*')

  if (config.start_date && config.date_field) {
    query = query.gte(config.date_field, config.start_date)
  }
  if (config.end_date && config.date_field) {
    query = query.lte(config.date_field, config.end_date)
  }

  const { data, error } = await query
  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  const grouped = new Map<string, number>()
  data?.forEach((row: Record<string, unknown>) => {
    const key = String(row[config.dimension] ?? 'Other')
    const val = Number(row[config.metric] ?? 0)
    grouped.set(key, (grouped.get(key) ?? 0) + 
      (config.metric_type === 'count' ? 1 : val))
  })

  const chartData = [...grouped.entries()].map(([name, value]) => ({
    name,
    value,
  }))

  await supabase.from('report_snapshots').insert({
    report_id,
    data: chartData,
  })

  return NextResponse.json({ data: chartData })
}
```

## Common mistakes

- **Executing raw SQL from user-configured query_config** — User-supplied SQL opens the door to SQL injection attacks, potentially exposing or destroying your entire database. Fix: Never execute raw SQL from user input. Construct Supabase queries programmatically using the query builder pattern (.from().select().gte().lte().eq()) based on the config values.
- **Not setting maxDuration for PDF export API routes** — PDF generation with @react-pdf/renderer can take more than the default 10-second serverless timeout for large reports, causing the export to fail. Fix: Add export const maxDuration = 30 to the API route file. This gives Vercel serverless functions 30 seconds to complete the PDF generation.
- **Loading all report data client-side for chart rendering** — Large datasets sent to the browser consume bandwidth and can crash the tab. Charts do not need raw row data — they need aggregated values. Fix: Aggregate data server-side in the API route and return only the chart-ready grouped data. The client receives dozens of data points, not thousands of rows.

## Best practices

- Never execute raw SQL from user input — use the Supabase query builder pattern for programmatic query construction
- Aggregate data server-side and return only chart-ready data to the client to minimize bandwidth
- Use Recharts (bundled with V0 projects) for all chart rendering — no additional packages needed
- Set maxDuration = 30 in export API routes for large PDF/CSV generation
- Use V0's Connect panel for Supabase provisioning so report data and metadata share one connection
- Save report snapshots for historical comparison without re-querying the source data

## Frequently asked questions

### What V0 plan do I need for a reporting tool?

V0 Free works for the basic build, but Premium ($20/month) is recommended because the report builder has complex interactive pages that benefit from prompt queuing.

### Can I connect to data sources other than Supabase?

The base build uses Supabase as the data source. You can extend it by adding API-based data sources that fetch from external services (Google Analytics, Stripe) and normalize the data into the same chart-ready format.

### How does PDF export work in a serverless environment?

@react-pdf/renderer runs entirely server-side in the API route — no browser APIs needed. Set maxDuration = 30 in the route config for larger reports. The generated PDF is returned as a downloadable response.

### Can team members collaborate on reports?

Yes. The sharing system lets you invite team members by email with view or edit permissions. Shared reports appear in their report library alongside their own reports.

### How do I deploy the reporting tool?

Click Share then Publish to Production in V0. Set SUPABASE_SERVICE_ROLE_KEY in the Vars tab without NEXT_PUBLIC_ prefix. Supabase connection is auto-configured from the Connect panel.

### Can RapidDev help build a custom reporting platform?

Yes. RapidDev has built 600+ apps including custom BI platforms with scheduled reports, white-label dashboards, and multi-source data integration. Book a free consultation to discuss your needs.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/reporting-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/reporting-tool
