Skip to main content

Command Palette

Search for a command to run...

Building a Real-Time JSON to CSV Converter in React

How we handle heterogeneous arrays, nested objects, and RFC 4180 escaping — all client-side with zero dependencies

Updated
7 min read
Building a Real-Time JSON to CSV Converter in React

JSON is how APIs talk. CSV is how spreadsheets talk. Converting between them sounds trivial — until you hit real-world data.

Objects with different keys. Nested structures three levels deep. Values with commas and quotes that break your output. Null fields that silently corrupt rows.

I built a JSON to CSV converter that handles all of this client-side, with real-time preview and zero dependencies. Here's the technical breakdown.

The Core Problem: JSON Arrays Aren't Tables

A CSV file is a grid. Fixed columns, fixed rows. Every row has the same number of fields.

A JSON array is... flexible. Consider this API response:

[
  { "name": "Alice", "email": "alice@example.com", "role": "admin" },
  { "name": "Bob", "phone": "+1234567890" },
  { "name": "Carol", "email": "carol@example.com", "department": "engineering" }
]

Three objects, four different keys. Alice has email and role. Bob has phone but no email. Carol has department that nobody else has.

Most naive converters either crash or silently drop fields. Here's how we handle it.

Union Schema: Collecting All Keys

Instead of using the first object's keys as the column headers (a common shortcut that loses data), we scan every object and build a union of all keys:

const keysSet = new Set<string>();
for (const obj of arr) {
  if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
    Object.keys(obj).forEach(k => keysSet.add(k));
  }
}
const keys = Array.from(keysSet);

For the example above, keys becomes ["name", "email", "role", "phone", "department"].

Then when building rows, if an object doesn't have a key, that cell is simply empty:

const rows = arr
  .filter(obj => typeof obj === "object" && obj !== null && !Array.isArray(obj))
  .map(obj => keys.map(k => escape((obj as Record<string, unknown>)[k])).join(","));

Output:

name,email,role,phone,department
Alice,alice@example.com,admin,,
Bob,,,+1234567890,
Carol,carol@example.com,,,engineering

Every field accounted for. No data loss.

Why Filter Non-Objects?

Real-world JSON arrays sometimes contain mixed types:

[{ "id": 1 }, null, "stray string", { "id": 2 }]

The filter typeof obj === "object" && obj !== null && !Array.isArray(obj) ensures only plain objects become rows. Primitives, nulls, and nested arrays are silently skipped rather than causing a crash.

CSV Escaping: RFC 4180 Compliance

CSV has a formal spec — RFC 4180. The rules are simple but easy to get wrong:

  1. Fields containing commas must be quoted

  2. Fields containing double quotes must be quoted, with internal quotes doubled

  3. Fields containing newlines must be quoted

  4. Null/undefined becomes an empty string

Here's the implementation:

const escape = (val: unknown): string => {
  if (val === null || val === undefined) return "";
  const str = typeof val === "object" ? JSON.stringify(val) : String(val);
  if (str.includes(",") || str.includes('"') || str.includes("\n")) {
    return `"${str.replace(/"/g, '""')}"`;
  }
  return str;
};

Examples:

Input Value Output
hello hello
John, Doe "John, Doe"
He said "hi" "He said ""hi"""
Line 1\nLine 2 "Line 1\nLine 2"
null (empty)
{ city: "NYC" } "{""city"":""NYC""}"

That last row is interesting — nested objects get JSON-stringified first, then the resulting string gets CSV-escaped. This preserves the data without flattening the structure.

Why Not Flatten Nested Objects?

Flattening (address.city, address.zip) is popular but creates problems:

  • Column names become unpredictable (items[0].product.name)

  • Deeply nested data generates dozens of sparse columns

  • Arrays of varying length can't flatten consistently

  • The original structure is lost — you can't reconstruct the JSON from the CSV

JSON-stringifying nested values keeps the CSV clean (one column per top-level key) while preserving the data for anyone who needs to parse it later.

Real-Time Conversion: No Button Required

The converter runs on every keystroke:

const convert = (value: string) => {
  setInput(value);
  setError("");
  setRowCount(0);
  if (!value.trim()) { setOutput(""); return; }

  try {
    const { csv, rowCount: count } = jsonToCsv(value);
    setOutput(csv);
    setRowCount(count);
  } catch (e: any) {
    setOutput("");
    setError(e.message || "Invalid JSON input.");
  }
};

No debouncing. JSON.parse() and the conversion loop are fast enough for typical payloads (up to tens of thousands of rows) that adding a delay would hurt the experience more than help.

The try-catch is essential — while typing, the JSON is frequently invalid (incomplete brackets, trailing commas). The error state clears the output and shows a message, then recovers instantly when the JSON becomes valid again.

Single Object Support

Not every input is an array. Sometimes you just have one object:

{ "name": "Alice", "email": "alice@example.com" }

Instead of throwing an error, the converter normalizes the input:

const parsed = JSON.parse(json);
const arr = Array.isArray(parsed) ? parsed : [parsed];

A single object becomes a one-row CSV. Simple, but it eliminates a common friction point.

File Upload with Smart Naming

Users can also upload .json files:

const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;
  setFileName(file.name);
  const text = await file.text();
  convert(text);
};

File.text() is the modern async alternative to FileReader — cleaner API, returns a Promise directly.

The filename is stored for download:

const handleDownload = () => {
  const blob = new Blob([output], { type: "text/csv;charset=utf-8;" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = fileName
    ? fileName.replace(/\.json$/i, ".csv")
    : "data.csv";
  a.click();
  URL.revokeObjectURL(url);
};

Upload users.json, download users.csv. The .json.csv extension swap happens automatically. If the input was pasted (no file), it defaults to data.csv.

Note the URL.revokeObjectURL(url) — without this, each download creates a memory leak. The blob URL stays in memory until the page unloads.

Prettify: A Small UX Win

A bonus feature — the Prettify button reformats messy JSON:

const prettify = () => {
  try {
    const parsed = JSON.parse(input);
    const pretty = JSON.stringify(parsed, null, 2);
    setInput(pretty);
  } catch {}
};

Users often paste minified API responses. One click makes it readable. The silent catch means the button does nothing on invalid JSON rather than showing an error.

Why No CSV Library?

Libraries like papaparse or csv-stringify handle edge cases well. But for this tool:

  • The CSV generation logic is ~15 lines

  • RFC 4180 compliance covers 99% of use cases

  • No streaming needed (entire dataset fits in memory)

  • One fewer dependency to maintain and bundle

The escape function, key union, and row mapping together form a complete CSV generator. Adding a library would increase bundle size for functionality we don't need.

SSR Considerations

The component uses navigator.clipboard and URL.createObjectURL — both browser-only APIs. In Next.js:

const JsonToCsv = dynamic(
  () => import("./JsonToCsv").then((mod) => mod.JsonToCsv),
  { ssr: false }
);

The SEO page (metadata, schemas, FAQ content) renders server-side. The interactive tool loads client-side only.

Try It

The live tool: JSON to CSV Converter

Paste a JSON array or upload a file. The CSV appears instantly. Download or copy with one click.

Key takeaways if you're building something similar:

  1. Build a union schema from all objects — don't assume consistent keys

  2. JSON-stringify nested values instead of flattening — simpler and lossless

  3. Implement RFC 4180 escaping — it's only 6 lines but prevents real bugs

  4. Skip debouncing for synchronous transforms — JSON.parse is fast enough

  5. Store the upload filename for smart download naming


Part of Ultimate Tools — free, privacy-first browser tools built with Next.js and TypeScript.

More from this blog

U

Ultimate Tools — Developer Blog

129 posts