Build CSV Import and the Settings Page
Build CSV Import and the Settings Page
Write a small CSV line parser
CSV looks simple until a field contains a comma or a quoted comma inside a
quoted field. Rather than pull in a dependency for this one contained
task, we’ll write a small parser that handles quoted fields correctly.
Open lib/actions.ts and add:
function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (char === '"') {
inQuotes = false;
} else {
current += char;
}
} else {
if (char === '"') {
inQuotes = true;
} else if (char === ",") {
result.push(current);
current = "";
} else {
current += char;
}
}
}
result.push(current);
return result;
}
It walks the line character by character, tracking whether it’s currently
inside a quoted field. A doubled quote ("") inside quotes is treated as
one literal quote character, matching the escaping exportCsvString uses
— which means a file this app exports can always be re-imported by this
same parser.
Write the import Server Action
Still in lib/actions.ts, add:
import { pool } from "./db";
export async function importCsvAction(formData: FormData) {
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return;
const text = await file.text();
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= 1) return;
const rows = lines.slice(1);
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const line of rows) {
const cols = parseCsvLine(line);
const [name, phone, email, company, notes, favorite] = cols;
if (!name) continue;
await client.query(
`INSERT INTO contacts (name, phone, email, company, notes, favorite)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
name,
phone || null,
email || null,
company || null,
notes || null,
String(favorite).toLowerCase() === "true",
]
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
revalidatePath("/");
redirect("/settings?imported=1");
}
Two details worth noticing:
- Server Actions can accept a
FilefromformData.get(...), the same
way any other field is read — file uploads don’t need a different
mechanism from the text fields you’ve already been reading. - The whole import runs inside one transaction: if any row fails partway
through (a malformed line, say), the transaction rolls back and none
of the rows are inserted, rather than leaving the database half-updated
with only some of the file imported. lines.slice(1)skips the header row, and?imported=1on the redirect
is a simple signal the Settings page can check to show a success
message.
Build the Settings page
Replace the placeholder in app/settings/page.tsx:
import { importCsvAction } from "@/lib/actions";
export const dynamic = "force-dynamic";
export default async function SettingsPage({
searchParams,
}: {
searchParams: { imported?: string };
}) {
return (
<>
<div className="page-heading">
<h1>Settings</h1>
</div>
<div className="settings-section">
<h2>Export contacts</h2>
<p>Download all your contacts as a CSV file.</p>
<a href="/api/export" className="btn">Download CSV</a>
</div>
<div className="settings-section">
<h2>Import contacts</h2>
<p>Upload a CSV with a header row: name, phone, email, company, notes, favorite.</p>
<form action={importCsvAction}>
<div className="field">
<input type="file" name="file" accept=".csv,text/csv" required />
</div>
<button type="submit" className="btn">Import CSV</button>
</form>
{searchParams.imported ? <div className="settings-note">Import complete.</div> : null}
</div>
</>
);
}
Test it
Download a CSV via the Export button, add a new row to it in a text editor
(matching the existing column order), then upload it back through the
Import form. You should be redirected to /settings?imported=1 with an
“Import complete” message, and your new row should now show up as a
contact on the Home page.
Checkpoint
You can export every contact to a CSV file and import a CSV file back in,
and a file this app exports can always be re-imported by the same app
without any manual editing.