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

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:
Fields containing commas must be quoted
Fields containing double quotes must be quoted, with internal quotes doubled
Fields containing newlines must be quoted
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:
Build a union schema from all objects — don't assume consistent keys
JSON-stringify nested values instead of flattening — simpler and lossless
Implement RFC 4180 escaping — it's only 6 lines but prevents real bugs
Skip debouncing for synchronous transforms — JSON.parse is fast enough
Store the upload filename for smart download naming
Part of Ultimate Tools — free, privacy-first browser tools built with Next.js and TypeScript.
