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
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
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.
1Create a reporting tool database schema in Supabase.23Create this table:45reports:6 id uuid primary key default gen_random_uuid()7 name text not null8 description text9 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 true12 created_by uuid references auth.users13 last_run_at timestamptz14 run_count integer default 015 created_at timestamptz default now()1617RLS: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).2122Insert 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 paramPro 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.
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.
1Create a Supabase Edge Function at supabase/functions/run-report/index.ts.23The function accepts POST requests with body: { report_id: string, params: Record<string, string>, page: number, page_size: number }.45Logic: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 }.1516Return 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.
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.
1Build the report viewer page at src/pages/Reports.tsx.23Layout: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.67Filter 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': Input11 - type='select': Select with query_config.params[n].options as SelectItems12- A 'Run Report' Button triggers the Edge Function call13- A 'Columns' Popover Button with a Checkbox list for toggling column visibility (TanStack Table columnVisibility state)1415DataTable:16- Columns are built dynamically from the response's columns array17- Server-side pagination: Previous/Next buttons update page state and re-call the Edge Function18- Show total row count: '1,234 rows'19- Loading state: show Skeleton rows during fetch2021Export bar (below table):22- 'Export CSV' Button: serializes current rows to CSV and triggers download23- 'Export PDF' Button: calls window.print() — add a print CSS class that hides everything except the tablePro 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.
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.
1// src/lib/export-utils.ts2import { format } from 'date-fns'34export type ReportColumn = {5 key: string6 label: string7}89export function exportToCSV(rows: Record<string, unknown>[], columns: ReportColumn[], reportName: string): void {10 const header = columns.map((c) => `"${c.label}"`).join(',')11 const body = rows12 .map((row) =>13 columns14 .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')2324 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 = url29 link.download = `${reportName.replace(/\s+/g, '-').toLowerCase()}-${format(new Date(), 'yyyy-MM-dd')}.csv`30 link.click()31 URL.revokeObjectURL(url)32}3334export function exportToPDF(reportName: string): void {35 const originalTitle = document.title36 document.title = reportName37 window.print()38 document.title = originalTitle39}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.
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.
1Add scheduled report delivery to the reporting tool.23New table: report_schedules4 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 run9 email_to text10 is_active boolean default true11 next_run_at timestamptz12 last_run_at timestamptz1314RLS: users can manage their own schedules (user_id = auth.uid()).1516Create 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 internally204. Format the rows as an HTML table215. Send via Resend API (use RESEND_API_KEY from Deno.env.get)226. Update last_run_at and calculate next_run_at based on frequency2324In 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
1import { format } from 'date-fns'23export type ReportColumn = {4 key: string5 label: string6 type: 'text' | 'number' | 'date' | 'badge'7}89export function exportToCSV(10 rows: Record<string, unknown>[],11 columns: ReportColumn[],12 reportName: string13): void {14 if (rows.length === 0) {15 console.warn('No rows to export')16 return17 }1819 const header = columns.map((c) => `"${c.label}"`).join(',')2021 const body = rows22 .map((row) =>23 columns24 .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')3334 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 = url39 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}4546export function exportToPDF(reportName: string): void {47 const original = document.title48 document.title = reportName49 window.print()50 document.title = original51}5253export 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.
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?
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation