CodingNic

CSV Export & Import

Build the CSV Export Route

CSV Export & Import 20 min read

Build the CSV Export Route

Build the CSV Export Route

Fetch every contact

Open lib/contacts.ts and add:

typescript
export async function getAllContactsForExport(): Promise<Contact[]> {
  const result = await pool.query(`SELECT * FROM contacts ORDER BY lower(name) ASC`);
  return result.rows;
}

This deliberately ignores search and pagination — an export should always
include every contact, regardless of whatever filter happens to be active
on the Home page.

Build the CSV string

Open lib/actions.ts and add:

typescript
import { getAllContactsForExport } from "./contacts";

export async function exportCsvString(): Promise<string> {
  const contacts = await getAllContactsForExport();
  const header = "name,phone,email,company,notes,favorite";
  const escape = (v: string | boolean | null) => {
    const s = v === null || v === undefined ? "" : String(v);
    return `"${s.replace(/"/g, '""')}"`;
  };
  const rows = contacts.map((c) =>
    [c.name, c.phone, c.email, c.company, c.notes, c.favorite].map(escape).join(",")
  );
  return [header, ...rows].join("\n");
}

Every field gets wrapped in double quotes, with any existing double quote
inside the value doubled (" becomes "") — that’s the standard CSV
escaping rule, and it’s what lets a note like He said "call anytime"
round-trip correctly instead of breaking the file’s structure.

Build the Route Handler

Create app/api/export/route.ts:

typescript
import { exportCsvString } from "@/lib/actions";

export const dynamic = "force-dynamic";

export async function GET() {
  const csv = await exportCsvString();
  return new Response(csv, {
    headers: {
      "Content-Type": "text/csv; charset=utf-8",
      "Content-Disposition": 'attachment; filename="contacts.csv"',
    },
  });
}

A Route Handler is a different kind of file from everything you’ve built
so far: instead of exporting a React component, route.ts exports a
function named after an HTTP method — GET, here — and returns a raw
Response. This is the right tool whenever a page needs to send something
that isn’t HTML: a file, a redirect with custom headers, a webhook
response, and so on.

Content-Disposition: attachment is what tells the browser to download
this response as a file named contacts.csv instead of trying to display
the CSV text as a page.

Test it

Visit http://localhost:3000/api/export directly in your browser. It
should download a contacts.csv file. Open it in a text editor or
spreadsheet app — you should see every contact you’ve created, one per
row, with the header row on top.

Checkpoint

Visiting /api/export downloads a correctly formatted CSV file containing
every contact, independent of any search or pagination state.