Skip to main content
RapidDev - Software Development Agency

How to Build a Reporting Tool with Lovable

Build a dynamic reporting tool in Lovable where report definitions live in a query_config JSONB column. A parameterized Supabase Edge Function runs each report's SQL safely, returns paginated rows to a DataTable with column toggles, and lets users export results as CSV or PDF — all without touching code for each new report.

What you'll build

  • A reports registry table storing name, description, and query_config JSONB per report
  • A parameterized Edge Function that safely executes report SQL with user-supplied filters
  • A DataTable with per-column visibility toggles powered by TanStack Table column visibility
  • Date-range and dropdown filter controls that feed parameters into the Edge Function
  • CSV export using the full result set returned from the Edge Function
  • PDF export using the browser's print stylesheet scoped to the report table
  • A report selector sidebar listing all saved reports with last-run timestamps
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2–2.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a dynamic reporting tool in Lovable where report definitions live in a query_config JSONB column. A parameterized Supabase Edge Function runs each report's SQL safely, returns paginated rows to a DataTable with column toggles, and lets users export results as CSV or PDF — all without touching code for each new report.

What you're building

Each report is a row in a reports table. The query_config JSONB column stores the SQL template, accepted parameter names, and column definitions. When a user opens a report and sets filters, the frontend sends the report ID and filter values to an Edge Function. The function fetches the report's query_config, substitutes the parameters using a safe parameterized query (no string concatenation), runs it against Supabase using the service role, and returns the rows plus column metadata.

The DataTable receives the rows and renders them with TanStack Table. Column visibility state is stored in React state and surfaces as a Popover with Checkbox items — users can hide columns they don't need. Sorting and pagination are server-side: filter changes trigger a new Edge Function call. For CSV export the current result rows are serialized with comma separation and downloaded as a Blob. For PDF the print stylesheet hides everything except the table, and window.print() is called programmatically.

The sidebar lists all report definitions. Clicking a report updates the active report ID in URL state (useSearchParams) so the URL is shareable.

Final result

A fully functional reporting tool where new reports are added by inserting a row into a database table — no code changes required.

Tech stack

LovableFrontend report viewer and builder
Supabase Edge FunctionsParameterized report query execution
SupabaseDatabase and reports registry
shadcn/uiDataTable, Sidebar, Popover, Checkbox
TanStack Table v8Column toggling, sorting, pagination
date-fnsDate range formatting for filter params

Prerequisites

  • Lovable Pro account for Edge Function generation
  • Supabase project with SUPABASE_SERVICE_ROLE_KEY saved in Cloud tab → Secrets
  • At least one data table in your Supabase project to report on
  • Basic understanding of SQL SELECT statements and WHERE clauses
  • Node 18+ for running any local type generation (optional)

Build steps

1

Create the reports registry schema

Prompt Lovable to create the reports table that acts as the registry for all report definitions. Each row defines a complete report: its SQL template, the parameter slots, and display metadata.

prompt.txt
1Create a reporting tool database schema in Supabase.
2
3Create this table:
4
5reports:
6 id uuid primary key default gen_random_uuid()
7 name text not null
8 description text
9 category text (e.g. 'Sales', 'Operations', 'Finance')
10 query_config jsonb not null stores: { sql: string, params: [{name, label, type: 'date'|'text'|'select', options?: string[]}], columns: [{key, label, type: 'text'|'number'|'date'|'badge'}] }
11 is_active boolean default true
12 created_by uuid references auth.users
13 last_run_at timestamptz
14 run_count integer default 0
15 created_at timestamptz default now()
16
17RLS:
18 Enable RLS on reports.
19 SELECT: authenticated users can read all active reports.
20 INSERT/UPDATE/DELETE: only service role (admins manage reports via Supabase dashboard).
21
22Insert two sample report rows:
231. name='Monthly Sales Summary', category='Sales', query_config with a sql template that selects from orders grouped by month, with a date range param (start_date, end_date)
242. name='User Signups by Day', category='Users', query_config with sql selecting from auth.users grouped by created_at date, with a date range param

Pro tip: Ask Lovable to also create a report_runs table (id, report_id, user_id, params_used jsonb, row_count int, duration_ms int, ran_at timestamptz) so you can show users their run history and cache repeated identical queries.

Expected result: The reports table is created with RLS. Two sample report rows exist. TypeScript types are generated. The app shell renders in the preview.

2

Build the parameterized report-runner Edge Function

Create the Edge Function that takes a report ID and parameter values, fetches the query_config, builds a safe parameterized query, and returns rows plus column definitions.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/run-report/index.ts.
2
3The function accepts POST requests with body: { report_id: string, params: Record<string, string>, page: number, page_size: number }.
4
5Logic:
61. Authenticate the caller using the Authorization header (check for a valid Supabase JWT using the anon key call createClient with the anon key and pass the user's JWT)
72. Fetch the report row from the reports table by report_id. If not found, return 404.
83. Extract query_config.sql from the report. The SQL contains named placeholders like :start_date, :end_date.
94. Replace each :param_name placeholder with a positional $1, $2, etc. and build a values array in matching order.
105. Append LIMIT $N OFFSET $M to the SQL for pagination.
116. Run the query using supabase.rpc or a raw postgres connection via the service role client.
127. Also run a COUNT(*) variant of the same query (wrap in SELECT COUNT(*) FROM (...) as t) for total row count.
138. Update reports.last_run_at = now() and increment run_count.
149. Return JSON: { rows: any[], total: number, columns: query_config.columns, page, page_size }.
15
16Return CORS headers on all responses including OPTIONS preflight.

Pro tip: For the actual SQL execution, use Supabase's pg library via the service role. Never use string concatenation to inject user values into SQL — always use positional parameters ($1, $2) to prevent SQL injection.

Expected result: The Edge Function deploys successfully. Posting a report_id with valid params returns rows and column definitions as JSON.

3

Build the report viewer with DataTable and column toggles

Ask Lovable to build the main report viewer page. A Sidebar on the left lists all reports. Selecting a report loads its filters and renders the results in a DataTable with column visibility toggles.

prompt.txt
1Build the report viewer page at src/pages/Reports.tsx.
2
3Layout:
4- Left Sidebar (shadcn/ui Sidebar or a simple resizable panel): lists all reports from the reports table grouped by category. Each item shows report name and last_run_at relative time. Clicking sets the active report ID in URL search params (?report=uuid).
5- Right main area: shows filter bar, DataTable, and export buttons.
6
7Filter bar:
8- Reads the active report's query_config.params array and renders the appropriate control per param type:
9 - type='date': shadcn/ui DatePickerWithRange (two date inputs)
10 - type='text': Input
11 - type='select': Select with query_config.params[n].options as SelectItems
12- A 'Run Report' Button triggers the Edge Function call
13- A 'Columns' Popover Button with a Checkbox list for toggling column visibility (TanStack Table columnVisibility state)
14
15DataTable:
16- Columns are built dynamically from the response's columns array
17- Server-side pagination: Previous/Next buttons update page state and re-call the Edge Function
18- Show total row count: '1,234 rows'
19- Loading state: show Skeleton rows during fetch
20
21Export bar (below table):
22- 'Export CSV' Button: serializes current rows to CSV and triggers download
23- 'Export PDF' Button: calls window.print() add a print CSS class that hides everything except the table

Pro tip: Store the column visibility state in localStorage keyed by report ID so users' column preferences persist across page refreshes.

Expected result: The report viewer renders with a sidebar of reports. Selecting a report and clicking Run Report populates the DataTable. Column toggles show and hide columns. Pagination works.

4

Add CSV and PDF export

Implement the export utilities as standalone TypeScript helper functions that the DataTable calls directly. Keep them decoupled from the UI components so they can be reused.

src/lib/export-utils.ts
1// src/lib/export-utils.ts
2import { format } from 'date-fns'
3
4export type ReportColumn = {
5 key: string
6 label: string
7}
8
9export function exportToCSV(rows: Record<string, unknown>[], columns: ReportColumn[], reportName: string): void {
10 const header = columns.map((c) => `"${c.label}"`).join(',')
11 const body = rows
12 .map((row) =>
13 columns
14 .map((c) => {
15 const val = row[c.key]
16 if (val == null) return '""'
17 const str = String(val).replace(/"/g, '""')
18 return `"${str}"`
19 })
20 .join(',')
21 )
22 .join('\n')
23
24 const csv = `${header}\n${body}`
25 const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
26 const url = URL.createObjectURL(blob)
27 const link = document.createElement('a')
28 link.href = url
29 link.download = `${reportName.replace(/\s+/g, '-').toLowerCase()}-${format(new Date(), 'yyyy-MM-dd')}.csv`
30 link.click()
31 URL.revokeObjectURL(url)
32}
33
34export function exportToPDF(reportName: string): void {
35 const originalTitle = document.title
36 document.title = reportName
37 window.print()
38 document.title = originalTitle
39}

Pro tip: For PDF, add a <style> tag in your report page that sets @media print { body > *:not(#report-table-container) { display: none !important; } }. This ensures only the table prints.

Expected result: Clicking Export CSV downloads a properly formatted CSV file. Clicking Export PDF opens the browser print dialog showing only the report table.

5

Add report scheduling and email delivery (optional enhancement)

Extend the tool by letting users schedule reports to run automatically and receive results by email. This uses pg_cron and a second Edge Function for delivery.

prompt.txt
1Add scheduled report delivery to the reporting tool.
2
3New table: report_schedules
4 id uuid primary key default gen_random_uuid()
5 report_id uuid references reports(id)
6 user_id uuid references auth.users(id)
7 frequency text 'daily' | 'weekly' | 'monthly'
8 params jsonb the filter params to use for the scheduled run
9 email_to text
10 is_active boolean default true
11 next_run_at timestamptz
12 last_run_at timestamptz
13
14RLS: users can manage their own schedules (user_id = auth.uid()).
15
16Create an Edge Function at supabase/functions/deliver-scheduled-reports/index.ts:
171. Accept a POST request (will be called by pg_cron)
182. Query report_schedules WHERE is_active = true AND next_run_at <= now()
193. For each schedule, call the existing run-report Edge Function internally
204. Format the rows as an HTML table
215. Send via Resend API (use RESEND_API_KEY from Deno.env.get)
226. Update last_run_at and calculate next_run_at based on frequency
23
24In the UI, add a 'Schedule' Button in the report viewer that opens a Dialog to create/edit a schedule for the current report.

Pro tip: Set up the pg_cron job in Supabase Dashboard → Database → Cron Jobs: SELECT cron.schedule('deliver-reports', '0 * * * *', $$SELECT net.http_post(url:='https://YOUR_PROJECT.supabase.co/functions/v1/deliver-scheduled-reports', headers:='{}', body:='{}')$$).

Expected result: A Schedule button appears in the report viewer. Creating a schedule saves to report_schedules. The Edge Function sends email reports on schedule.

Complete code

src/lib/export-utils.ts
1import { format } from 'date-fns'
2
3export type ReportColumn = {
4 key: string
5 label: string
6 type: 'text' | 'number' | 'date' | 'badge'
7}
8
9export function exportToCSV(
10 rows: Record<string, unknown>[],
11 columns: ReportColumn[],
12 reportName: string
13): void {
14 if (rows.length === 0) {
15 console.warn('No rows to export')
16 return
17 }
18
19 const header = columns.map((c) => `"${c.label}"`).join(',')
20
21 const body = rows
22 .map((row) =>
23 columns
24 .map((c) => {
25 const val = row[c.key]
26 if (val == null) return '""'
27 const str = String(val).replace(/"/g, '""')
28 return `"${str}"`
29 })
30 .join(',')
31 )
32 .join('\n')
33
34 const csv = `${header}\n${body}`
35 const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
36 const url = URL.createObjectURL(blob)
37 const a = document.createElement('a')
38 a.href = url
39 a.download = `${reportName.replace(/\s+/g, '-').toLowerCase()}-${format(new Date(), 'yyyy-MM-dd')}.csv`
40 document.body.appendChild(a)
41 a.click()
42 document.body.removeChild(a)
43 URL.revokeObjectURL(url)
44}
45
46export function exportToPDF(reportName: string): void {
47 const original = document.title
48 document.title = reportName
49 window.print()
50 document.title = original
51}
52
53export function formatCellValue(value: unknown, type: ReportColumn['type']): string {
54 if (value == null) return '—'
55 if (type === 'date' && typeof value === 'string') {
56 return format(new Date(value), 'MMM d, yyyy')
57 }
58 if (type === 'number' && typeof value === 'number') {
59 return value.toLocaleString()
60 }
61 return String(value)
62}

Customization ideas

Saved filter presets

Add a report_presets table (report_id, user_id, name, params jsonb). Let users save the current filter values as a named preset and reload them from a Select in the filter bar.

Chart view toggle

Add a chart icon button next to the Columns toggle. When active, render a Recharts BarChart or LineChart using the first numeric column as the value and the first date/text column as the axis label.

Report builder UI

Add an admin-only Report Builder page where authorized users can construct a new report definition by selecting tables, columns, and filter fields through a form — which writes to the reports table without requiring SQL knowledge.

Shareable report links

Encode the report ID and current filter params as base64 in the URL. Add a Share button that copies the full URL to clipboard so colleagues can open the same filtered view directly.

Conditional row formatting

Add a row_styles jsonb column to reports that maps column value thresholds to Tailwind background colors (e.g. revenue < 0 = red-50). Apply these in the DataTable cell renderer.

Report access control

Add a report_access table linking reports to roles. Fetch only reports the current user's role can access, using a Supabase RPC function that joins with the user's profile role.

Common pitfalls

Pitfall: Building SQL queries by concatenating user-supplied filter values as strings

How to avoid: Always use parameterized queries with positional placeholders ($1, $2). The Edge Function should replace :param_name tokens with positional indices and pass values in a separate array.

Pitfall: Fetching all report rows before applying pagination

How to avoid: Always apply LIMIT and OFFSET in the SQL itself, inside the Edge Function. Pass page and page_size from the frontend and calculate OFFSET = (page - 1) * page_size.

Pitfall: Using the VITE_ prefix for service role key secrets

How to avoid: Store SUPABASE_SERVICE_ROLE_KEY (no VITE_ prefix) in Cloud tab → Secrets. It is only available inside Edge Functions via Deno.env.get('SUPABASE_SERVICE_ROLE_KEY').

Pitfall: Not adding CORS headers to Edge Function responses

How to avoid: Return 'Access-Control-Allow-Origin': '*' on all responses and handle OPTIONS preflight requests with a 200 response containing the same CORS headers.

Pitfall: Enabling RLS on the reports table but forgetting to add SELECT policy

How to avoid: Add an explicit RLS policy: CREATE POLICY 'authenticated read' ON reports FOR SELECT TO authenticated USING (is_active = true).

Best practices

  • Store SQL templates in the database, not in Edge Function source code — this lets you add new reports without a code deployment
  • Validate all param types before substituting them into the SQL template — reject non-date strings for date params, reject unknown values for enum params
  • Cap page_size at a hard maximum (e.g. 500) in the Edge Function regardless of what the frontend requests
  • Use count-only queries (SELECT COUNT(*) FROM (...)) for pagination totals — never load all rows just to count them
  • Log every report run to a report_runs table including params used, duration, and row count for debugging and audit purposes
  • Scope report access with RLS or a separate report_access table — not every user should see every report
  • Cache identical report runs in a report_cache table keyed by (report_id, params_hash) with a TTL — identical requests within 5 minutes serve the cached result
  • Provide column type metadata in query_config.columns so the DataTable can format numbers, dates, and badges consistently without frontend code changes

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a reporting tool where report definitions are stored as rows in a Supabase table with a query_config JSONB column containing the SQL template and parameter definitions. My Edge Function fetches the config and runs parameterized queries. How should I structure the query_config schema to support date range filters, dropdown filters, and optional filters? Also, what's the safest way to replace named placeholders like :start_date with positional $1 parameters in TypeScript?

Lovable Prompt

Add a report scheduling feature to my reporting tool. Create a report_schedules table with fields: report_id, user_id, frequency (daily/weekly/monthly), params jsonb, email_to, is_active, next_run_at. Add a Schedule button in the report viewer that opens a Dialog form. Create a deliver-scheduled-reports Edge Function that queries due schedules, runs each report using the existing run-report Edge Function, and emails the result as an HTML table using Resend. Use RESEND_API_KEY from Deno.env.get.

Build Prompt

In my Lovable project, I have a reporting tool with a run-report Edge Function. The function is supposed to replace :param_name placeholders in SQL with positional $1 parameters, but Supabase's supabase-js client doesn't support raw parameterized queries directly. Suggest the best approach: should I use the Postgres.js library via Deno import, use Supabase RPC with a dynamic SQL function, or call the Supabase REST API directly? Show the Edge Function code for whichever approach is safest.

Frequently asked questions

Can I run reports against tables I didn't create in this project?

Yes. The Edge Function runs with the service role key which has access to all tables in your Supabase project. You can write SQL in query_config that queries any table, including tables created by other parts of your app. Just make sure those tables exist before running the report.

How do I prevent users from seeing each other's data in reports?

Add a user_id filter to every report's SQL template — for example, WHERE user_id = :current_user_id. In the Edge Function, inject the authenticated user's ID as a required parameter regardless of what the frontend sends. Never trust the frontend to supply the user ID for security-sensitive filters.

What happens if a report query takes more than 30 seconds?

Supabase Edge Functions have a 30-second timeout on the free tier and 150 seconds on paid plans. For long-running reports, add a query timeout parameter to the SQL and consider running heavy reports asynchronously: the Edge Function kicks off the query, stores the result in a report_results table, and the frontend polls for completion.

Can I add charts to my reports?

Yes. The column definitions in query_config can include a visualize field. When the DataTable receives columns with visualize: 'bar' or visualize: 'line', render a Recharts chart above the table using the rows data. The DataTable and chart share the same data array — no second fetch needed.

Why does the CSV download include extra quote characters?

This is correct CSV escaping. Any field that contains a double quote is escaped by doubling it (" becomes ""). Most spreadsheet applications, including Excel and Google Sheets, parse this correctly. If you see doubled quotes in your editor, the file itself is correct — open it in a spreadsheet app to verify.

How do I add a new report without touching code?

Insert a new row into the reports table via Supabase Dashboard → Table Editor. Populate query_config with the SQL template and param definitions. The next time a user opens the reporting tool, the new report appears in the sidebar automatically — no Lovable prompt or code change needed.

Can I get expert help setting this up for my specific data model?

Yes. The RapidDev team helps founders configure reporting tools against their existing Supabase schemas, including writing the query_config templates and setting up scheduled delivery. Reach out at rapidevelopers.com.

How do I handle reports that need joins across multiple tables?

Write the full JOIN SQL in query_config.sql. For example: SELECT o.id, u.email, o.total FROM orders o JOIN auth.users u ON o.user_id = u.id WHERE o.created_at BETWEEN :start_date AND :end_date. The Edge Function runs the SQL as-is against your database. There are no restrictions on JOIN complexity.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.