Feature spec
BeginnerCategory
forms-data
Build with AI
2-4 hours with Lovable or V0 + Supabase
Custom build
2-4 days custom dev
Running cost
$0/mo up to 1K users; $25-50/mo at 10K users
Works on
Everything it takes to ship Data Export — parts, prompts, and real costs.
Data export needs a trigger button, a CSV generator (papaparse for under 5K rows), a server-side streaming endpoint for larger datasets, and an optional email delivery path for exports that take over 10 seconds. With Lovable or V0 you can ship a working download button in 2–4 hours for $0/month. The cost only appears if you add async email delivery for large exports (Resend at $0–20/mo).
What Data Export Actually Is
Data export converts rows from your database into a downloadable file — CSV for spreadsheets, Excel (.xlsx) for formatted reports, or JSON for developers. The trigger is usually a Download button on a data table, with optional filters (date range, column selector) before the export fires. The implementation splits into two cases: small datasets (under 5K rows) can be generated entirely in the browser with papaparse, while large datasets need a server-side streaming endpoint that pipes Postgres results directly to the response without loading everything into memory. GDPR adds a third pattern: a one-click data portability export that packages all of a user's personal data across multiple tables into a single ZIP file.
What users consider table stakes in 2026
- One-click CSV download from any data table — the button should be visible on the table itself, not buried in a settings menu
- Excel (.xlsx) export option with proper bold column headers and auto-sized columns — not just a CSV renamed with .xlsx extension
- Date range and column selector UI before exporting so users get exactly what they need rather than the entire table
- Progress indicator for exports that take more than 2 seconds — a spinner at minimum, a percentage bar for datasets over 1K rows
- Email delivery for exports that take more than 10 seconds, with a download link that stays valid for at least 24 hours
- Export history panel showing previous exports with timestamp, row count, format, and filters applied
Anatomy of the Feature
Six components. The client-side CSV path is a single-prompt task. The server-side streaming endpoint, Excel generation, and email delivery require separate prompts or separate functions.
Export trigger UI
UIA shadcn/ui Button with a Download icon from lucide-react. For multi-format support, wraps in a shadcn/ui DropdownMenu with CSV, Excel, and JSON options. A date range picker using react-day-picker sits above the table as a filter. A column selector using Radix UI Checkbox list lets users exclude irrelevant fields. Both filters feed into the export query as parameters.
Note: Show the row count next to the export button after filters are applied — 'Export 2,847 rows as CSV' sets expectations before the download starts.
Client-side CSV generator
Backendpapaparse's Papa.unparse() converts a JavaScript array of objects to a CSV string. Column headers come from a human-readable mapping object (e.g., { user_id: 'User ID', created_at: 'Date Created' }) rather than raw database column names. The resulting string is wrapped in a Blob, a URL is created with URL.createObjectURL(), and a programmatic anchor click triggers the browser download. Runs in under 200ms for up to 5K rows.
Note: Always pass { quotes: true } to Papa.unparse() — field values that contain commas (e.g., addresses, notes) or line breaks corrupt the CSV in Excel without quoting.
Server-side streaming export
BackendA Supabase Edge Function (Deno) or Next.js Route Handler that streams a Postgres query result directly to the HTTP response as CSV without loading all rows into memory. Uses the Supabase service role key to bypass RLS for admin exports. Sets Content-Disposition: attachment and Content-Type: text/csv response headers. For admin exports, adds a role check before the query — never expose the service role key to the client.
Note: Use Postgres cursor-based pagination (FETCH NEXT N ROWS) inside the stream for datasets over 100K rows to avoid Postgres statement timeouts.
Excel export generator
BackendExcelJS running in a Supabase Edge Function (Deno-compatible) or Next.js Route Handler. Creates a workbook, adds a worksheet, applies bold formatting and a background color to the header row, auto-sizes columns based on content width, then streams the result as application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. Column widths are calculated by sampling the first 100 rows of each column.
Note: ExcelJS in Deno sometimes hits compatibility issues with specific versions — if it fails in an Edge Function, move the Excel generation to a Next.js Route Handler running in Node.js instead.
Background export with email delivery
ServiceFor datasets over 10K rows: the export button triggers a Supabase Edge Function asynchronously (fire-and-forget), which runs the large query, writes the file to Supabase Storage with a signed URL expiring in 48 hours, and sends a download email via Resend. The UI immediately shows 'Your export is being prepared — we'll email you the download link.' The user's email arrives within 1–5 minutes depending on dataset size.
Note: Store the signed URL expiry timestamp in the export_logs table so the Export History page can show 'link expires in 2 days' and offer a Regenerate Link button when the URL is expired.
Export history log
DataA Supabase `export_logs` table records user_id, format, row_count, filters_applied (JSONB), file_url, and expires_at. Shown in a collapsible Export History section on the dashboard so users can re-download recent exports without re-running the query. Also serves as a GDPR audit trail showing what data was exported and when.
Note: Index on user_id + created_at DESC — most users only look at their last 10 exports, but the table grows unboundedly over time and needs the index once it reaches thousands of rows.
The data model
One table logs all exports for audit and re-download. Run this in the Supabase SQL editor.
1create table public.export_logs (2 id uuid primary key default gen_random_uuid(),3 user_id uuid references auth.users(id) on delete cascade not null,4 created_at timestamptz not null default now(),5 format text not null check (format in ('csv', 'excel', 'json')),6 row_count integer,7 filters jsonb default '{}'::jsonb,8 file_url text,9 expires_at timestamptz10);1112alter table public.export_logs enable row level security;1314-- Users can only see and create their own export logs15create policy "Users manage own export logs"16 on public.export_logs for all17 using (auth.uid() = user_id)18 with check (auth.uid() = user_id);1920-- No update or delete by users — logs are immutable for audit purposes21create policy "No updates to export logs"22 on public.export_logs for update23 using (false);2425create policy "No deletes to export logs"26 on public.export_logs for delete27 using (false);2829create index export_logs_user_recent_idx30 on public.export_logs (user_id, created_at desc);Heads up: The immutable log policy (no UPDATE/DELETE by users) provides an audit trail for GDPR data portability requests and compliance reviews. Admins can still clean up old records via the Supabase service role key if needed.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
V0's Next.js Route Handlers are the natural fit for server-side streaming CSV and Excel export — ExcelJS works natively in Node.js and streaming Postgres results is a well-documented Next.js pattern. Best choice for admin dashboards with large datasets.
Step by step
- 1Prompt V0 with the spec below; it will generate the export trigger UI, the client-side CSV path for small datasets, and the Route Handler for server-side streaming
- 2Add environment variables in the V0 Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY (for the admin export Route Handler)
- 3Run the export_logs SQL schema in the Supabase SQL editor
- 4Test small export (under 1K rows) from the UI — confirm the CSV file downloads and opens correctly in Excel with proper column headers
- 5Test large export by temporarily setting the client-side threshold to 10 rows and confirming the Route Handler streaming path is triggered
Build a data export feature in Next.js App Router with Supabase and shadcn/ui. Export trigger UI: a shadcn/ui Button with DownloadIcon (lucide-react) and a DropdownMenu showing CSV, Excel, JSON options. Date range filter using react-day-picker with two date inputs (startDate, endDate). Column selector: Radix UI Checkbox list with human-readable labels for each exportable column. Export logic split by dataset size: under 5,000 rows: client-side, query Supabase with the anon key scoped to auth.uid(), use papaparse Papa.unparse({ data: rows, quotes: true, columns: selectedColumns }) and trigger download via Blob URL. Over 5,000 rows: call /api/export route handler. Route handler /api/export: accept POST with { format, startDate, endDate, columns, userId }; verify the userId matches the session user; for CSV: query Supabase with service role key, stream results as text/csv with Content-Disposition attachment header; for Excel: use ExcelJS to create a workbook with bold header row (font: { bold: true }, fill: { type: 'pattern', fgColor: { argb: 'FFE2E8F0' } }), auto-size columns, stream as .xlsx. Log every export to Supabase `export_logs` table (user_id, format, row_count, filters JSONB, created_at). Export history: query last 10 export_logs rows for current user, display in a collapsible DataTable below the main table. UI states: button loading spinner while generating, download started toast on success, error toast with retry on failure, 'No rows match filters' empty state, row count shown next to export button ('Export 2,847 rows').Where this path bites
- Supabase service role key must be added manually to Vercel environment variables — never expose it in the client bundle
- The Tailwind v3/v4 mismatch can affect the column selector and date picker UI components — check that generated styles match your project's Tailwind version
Third-party services you'll need
Client-side CSV and JSON export is entirely free. You only pay for server infrastructure and optional email delivery for large async exports.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| papaparse | Browser and Node.js CSV generation — converts JS arrays to CSV strings with proper quoting | Fully free (MIT) | Free, open-source |
| ExcelJS | Excel .xlsx generation in Node.js Route Handlers or Deno Edge Functions with styled headers | Fully free (MIT) | Free, open-source |
| Supabase Storage | Temporary file storage for async large exports — signed URLs with configurable expiry | 1GB Storage included in free tier | Included in Pro $25/mo (100GB) |
| Resend | Email delivery of export download links for large async exports | 3K emails/mo free | Pro $20/mo (50K emails/mo) (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Client-side CSV with papaparse is entirely free. Supabase free tier covers export_logs storage. Email delivery stays within Resend's free tier even with frequent exports.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Browser tab crashes on large in-memory exports
Symptom: Client-side CSV generation with papaparse on 50K+ rows loads the entire result set into a JavaScript array in the browser tab's memory. On mobile devices with limited RAM, this causes the tab to crash silently — the download never starts and the user sees the tab reload with no error message. Desktop Chrome handles larger sets but can still stall on 100K+ rows.
Fix: Set a threshold of 5,000 rows for client-side generation. For anything larger, call a server-side streaming endpoint (Next.js Route Handler or Supabase Edge Function) that pipes the Postgres query result directly to the HTTP response stream using ReadableStream — never loads all rows into memory at once. Display a row count before the user clicks export so they know which path will be used.
Special characters corrupt CSV when opened in Excel
Symptom: CSV files where field values contain commas (e.g., '123 Main St, Suite 4'), double quotes, or line breaks (multiline notes, addresses) cause Excel to misalign columns. The row data spills across multiple columns and the file appears broken. This is a CSV quoting problem — the values need to be wrapped in double quotes with internal quotes escaped.
Fix: Always pass `{ quotes: true }` to Papa.unparse() — this forces every field to be wrapped in double quotes regardless of content. For Excel output specifically, use ExcelJS instead of CSV since it stores values as typed cell data without any quoting ambiguity. Add a note in your export UI: 'For formatted Excel output with headers and correct column alignment, use the Excel option.'
RLS blocks admin export of all users' data
Symptom: An admin export that uses the Supabase anon key hits RLS policies that restrict reads to `WHERE auth.uid() = user_id` — returning only the admin's own rows, not all users' data. The admin sees an export that looks complete but contains only their own records with no error or warning.
Fix: Admin exports must use the Supabase service role key in a server-side function (Route Handler or Edge Function). Never expose the service role key to the client bundle — it bypasses all RLS and gives full database access. Add an admin role check at the top of the function before running the query: verify the session user has the admin role in your roles table before the service role key is used.
Async export signed URL expires before the user checks their email
Symptom: A signed URL set to 1-hour expiry is emailed to the user for a large export. The user is in a meeting and opens the email 2 hours later — the link returns 403 Forbidden and the export file is gone. This is a common failure for any async export where expiry defaults are left at the Supabase default.
Fix: Set signed URL expiry to 48–72 hours for export download links. Store the `expires_at` timestamp in the `export_logs` table and display it in the Export History UI as 'link valid until [date]'. Add a Regenerate Link button that checks if the URL has expired and issues a new signed URL for the same file from Storage — this avoids re-running the expensive export query.
Best practices
Show the row count on the export button after filters are applied — 'Export 2,847 rows as CSV' sets expectations and prevents accidental full-table exports
Always use quotes: true in Papa.unparse() — the cost is a slightly larger file; the benefit is that CSV files containing commas in field values open correctly in every spreadsheet app
Map database column names to human-readable headers before export — users should see 'Date Created' not 'created_at', 'User Email' not 'user_id'
Route exports over 5K rows through a server-side streaming endpoint regardless of device — client-side generation on mobile is unreliable past a few thousand rows
Log every export to export_logs even for client-side downloads — the audit trail is valuable for GDPR compliance and for helping users who claim a file was corrupted
For async large exports, set signed URL expiry to 48 hours minimum and display the expiry date in the email and in the Export History panel
Never expose the Supabase service role key to the client — admin exports must happen exclusively in Route Handlers or Edge Functions with a server-side session check
When You Need Custom Development
The papaparse + ExcelJS pattern covers the vast majority of export use cases. A few requirements push beyond what a standard AI-generated build handles:
- Export must match a specific Excel template with pre-existing formulas, pivot tables, charts, or branded headers — not a plain data dump
- Scheduled automated exports required: daily CSV delivered to an S3 bucket, FTP server, or Google Drive at a fixed time without any user action
- GDPR 'right to data portability' package: a single endpoint that packages all of a user's personal data across 10+ tables into a structured ZIP file with a data manifest
- Export volume at millions of rows where streaming from Postgres directly to S3 via COPY TO is the only performant path
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How do I add a 'Download CSV' button to a data table?
Fetch the table rows from Supabase, pass the array to papaparse's Papa.unparse() with `{ quotes: true }` and a column header mapping, then trigger the download by creating a Blob URL and programmatically clicking a hidden anchor element. The full sequence: `const csv = Papa.unparse({ data: rows, fields: columnOrder }); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'export.csv'; a.click();`. This is a single-prompt task for both Lovable and V0.
Can I export to Excel instead of CSV?
Yes — use ExcelJS in a server-side function (Next.js Route Handler or Supabase Edge Function). ExcelJS creates a proper .xlsx file with typed cell values, bold header rows, and auto-sized columns. It handles numbers, dates, and strings correctly as distinct cell types, which CSV cannot do. For small datasets, XLSX.js (SheetJS) can run in the browser, but server-side ExcelJS is more reliable for large files and gives you control over styling.
How do I let users filter data before exporting?
Add a date range picker (react-day-picker) and a column selector (Radix UI Checkbox list) above the export button. These filters feed directly into the Supabase query as .gte(), .lte(), and .select() parameters before the export runs. Show the resulting row count on the export button after filtering so users know what they're about to download. Store the applied filters in the export_logs table for audit trail purposes.
What's the best way to handle exports with 100,000+ rows without crashing the browser?
Never run 100K+ row exports in the browser. Route anything over 5K rows to a Next.js Route Handler or Supabase Edge Function that streams the Postgres result directly to the HTTP response using ReadableStream. For datasets where the export takes over 30 seconds, switch to an async model: the user clicks export, a background function runs the query, uploads the result to Supabase Storage, and emails a signed URL download link. Display 'Your export is being prepared — we'll email you the link' immediately after the click.
How do I email a large export as a download link?
Run the export in a Supabase Edge Function triggered asynchronously (fire-and-forget from the client). Inside the function: run the Postgres query with the service role key, write the result to Supabase Storage with a 48-hour signed URL expiry, then send the signed URL to the user's email via Resend. Store the signed URL and expiry in export_logs. The Resend free tier (3K emails/month) covers most apps at low to mid scale.
Does Supabase support streaming CSV exports directly from the database?
Supabase Edge Functions (Deno) support ReadableStream responses, which lets you pipe a Postgres query result row-by-row to the HTTP response without loading the full result set into memory. Next.js Route Handlers support the same pattern with Node.js streams. Neither approach uses the raw Postgres COPY TO command directly — they iterate the query result with a cursor and write each chunk. For very large exports (millions of rows), consider using Supabase's pg_dump equivalent or a dedicated ETL service.
How do I rename database column names to human-readable headers in the export?
Define a header mapping object before the export runs: `const headers = { created_at: 'Date Created', user_id: 'User ID', stripe_amount: 'Amount ($)' }`. Pass this to Papa.unparse() as the `fields` array and `transformHeader` option, or manually map before unparse: `const mapped = rows.map(r => ({ 'Date Created': r.created_at, 'User ID': r.user_id }))`. This keeps database internals out of the spreadsheet and makes exported files usable by non-technical recipients.
Need this feature production-ready?
RapidDev builds data export into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.