# How to Add Data Export to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): A 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.
- **Client-side CSV generator** (backend): papaparse'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.
- **Server-side streaming export** (backend): A 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.
- **Excel export generator** (backend): ExcelJS 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.
- **Background export with email delivery** (service): For 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.
- **Export history log** (data): A 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.

## Data model

One table logs all exports for audit and re-download. Run this in the Supabase SQL editor.

```sql
create table public.export_logs (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  created_at timestamptz not null default now(),
  format text not null check (format in ('csv', 'excel', 'json')),
  row_count integer,
  filters jsonb default '{}'::jsonb,
  file_url text,
  expires_at timestamptz
);

alter table public.export_logs enable row level security;

-- Users can only see and create their own export logs
create policy "Users manage own export logs"
  on public.export_logs for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- No update or delete by users — logs are immutable for audit purposes
create policy "No updates to export logs"
  on public.export_logs for update
  using (false);

create policy "No deletes to export logs"
  on public.export_logs for delete
  using (false);

create index export_logs_user_recent_idx
  on public.export_logs (user_id, created_at desc);
```

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 paths

### Lovable — fit 4/10, 2-3 hours

Lovable handles client-side CSV export in a single prompt — papaparse is common in its training data and the export button wired to a Supabase query is a standard scaffold. Large-dataset streaming and email delivery require separate follow-up prompts.

1. Open your existing Lovable project (or create a new one with Supabase connected) and paste the prompt below in Agent Mode
2. Test the CSV export on a table with under 1,000 rows first — confirm the download triggers, the file opens in Excel correctly, and column names use the human-readable mapping
3. For Excel export, add a follow-up prompt: 'Add an Excel export option using ExcelJS in a Supabase Edge Function — bold header row, auto-sized columns, download as .xlsx'
4. If you need email delivery for large exports, open the Cloud tab and connect Resend in the Connectors section, then add: 'For exports over 5,000 rows, run the query in a background Edge Function, upload to Supabase Storage, and send a download link via Resend to the user's email'
5. Test the date range filter by selecting a 7-day window and confirming the exported row count matches the filtered table view

Starter prompt:

```
Add data export to the dashboard. Export trigger: a 'Download' button with a dropdown showing CSV, Excel (.xlsx), and JSON options. Above the main data table, add a date range filter (react-day-picker, two date inputs: start and end) and a column selector (Radix UI Checkbox list showing all available columns). When the user clicks an export format: 1) Query Supabase for rows matching the current date range and column filters, using auth.uid() to scope to the current user's data only. 2) For CSV: use papaparse Papa.unparse() with quotes: true and a human-readable header mapping (e.g., created_at → 'Date Created', user_id → 'User ID'). Create a Blob URL and trigger a programmatic anchor download. 3) For JSON: JSON.stringify the rows array with 2-space indent, create a Blob, download as .json. Show a loading spinner on the button while the query is running. On success, show a toast: 'Export started — your file should download automatically.' On error, show: 'Export failed — try again or contact support.' Log each export to a Supabase `export_logs` table (user_id, format, row_count, filters JSONB, created_at). Show the last 10 exports in a collapsible 'Export History' section below the table. UI states: button loading while exporting, empty date range means export all rows, empty column selector means export all columns, no data matches filter shows 'No rows match your filters — adjust the date range.'
```

Limitations:

- Large in-memory exports (over 5K rows) will cause browser tab crashes on mobile devices — route anything over 5K rows through a server-side streaming endpoint rather than client-side papaparse
- ExcelJS in a Deno Edge Function sometimes hits import compatibility issues — if it fails, add a follow-up prompt to move Excel generation to a Next.js Route Handler
- Resend connector setup in Lovable Cloud requires manually adding the API key in the Secrets section — this is a separate step from the initial export prompt

### V0 — fit 5/10, 2-4 hours

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.

1. Prompt 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
2. Add 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)
3. Run the export_logs SQL schema in the Supabase SQL editor
4. Test small export (under 1K rows) from the UI — confirm the CSV file downloads and opens correctly in Excel with proper column headers
5. Test large export by temporarily setting the client-side threshold to 10 rows and confirming the Route Handler streaming path is triggered

Starter prompt:

```
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').
```

Limitations:

- 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

### Custom — fit 3/10, 2-4 days

Custom development is justified only for complex export requirements: branded Excel templates with pre-existing formulas, scheduled automated exports to external destinations, or GDPR data portability packages across multiple tables.

1. Build a scheduled export pipeline: pg_cron triggers a Supabase Edge Function daily at 6am that queries the data, uploads a CSV to S3 or Google Drive via their APIs, and notifies the recipient
2. Build a branded Excel template system: ExcelJS reads a template .xlsx file from Supabase Storage, writes data into specific named ranges, preserves existing formulas and charts, and saves the result
3. Build a GDPR data portability package: a single endpoint that queries all tables containing the user's personal data, packages them into a ZIP with a README, and emails the download link

Limitations:

- Most of this work — papaparse, ExcelJS, streaming — is boilerplate regardless of implementation approach; custom dev adds value only at the integration layer
- For exports that are essentially plain data dumps, the AI-generated path produces the same result at a fraction of the cost

## Gotchas

- **Browser tab crashes on large in-memory exports** — 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** — 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** — 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** — 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

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

---

Source: https://www.rapidevelopers.com/app-features/data-export
© RapidDev — https://www.rapidevelopers.com/app-features/data-export
