Convert CSV to JSON

Turn a translated spreadsheet into nested JSON ready for Laravel JSON files or any JavaScript i18n library.

Rebuilding a nested bundle from a flat spreadsheet

This is the import leg for JavaScript projects. A translator has returned a two-column sheet, and your front end needs nested JSON that vue-i18n, i18next, React Intl or a Laravel JSON language file can load. The converter reads dot notation in the key column and expands it back into a real object tree.

Input — es.csv

key,value
welcome,"¡Bienvenido de nuevo, :name!"
cart.empty,"Tu carrito está vacío"
cart.items,":count artículo|:count artículos"

Output — es.json

{
    "welcome": "¡Bienvenido de nuevo, :name!",
    "cart": {
        "empty": "Tu carrito está vacío",
        "items": ":count artículo|:count artículos"
    }
}

The expansion matters because i18n libraries look keys up by path. Given a flat object whose keys literally contain dots, t('cart.empty') finds nothing and most libraries fall back to rendering the key itself — so users see cart.empty printed on the page.

The first column is the key and the second is the value. Working columns a translator used — source text, context, notes, status — are not part of the bundle, so trim the sheet to two columns before converting.

Values are copied verbatim. Placeholders, pipe-separated plural forms, embedded HTML and any Unicode in the target language survive unchanged, and the output is written as UTF-8 without ASCII escaping, so accented characters and non-Latin scripts appear literally rather than as \u00e9 sequences. That is more readable in review and equally valid to every parser you are likely to meet.

The key collision that silently drops a string

Expanding dot notation into a tree has one structural failure mode, and it is worth understanding because it produces no error at all. A key cannot be both a leaf and a branch: a single position in the tree holds either a string or an object, never both.

These two rows cannot coexist

key,value
cart,"Cart"
cart.empty,"Your cart is empty"

The tree needs cart to be the string "Cart" and simultaneously an object containing empty. One of them wins depending on row order, the other disappears, and nothing reports the loss. You discover it when a label goes missing from the interface weeks later.

This happens naturally as products grow: a group of keys exists under cart.*, then someone needs a heading for the section itself and adds a bare cart. The fix is a naming convention — use cart.title or cart.label for the parent-level string so every leaf sits at the same depth as its siblings.

Detect it before importing: flag any key that is also a prefix of another key. It is a few lines of code and it catches the whole class of problem in one pass.

A related case is worth watching for in bundles that contain lists. Numeric path segments such as onboarding.steps.0 expand into object keys rather than a real array, which most i18n libraries handle fine but any code doing .map() over the result will not. If order matters, confirm the shape your runtime expects before shipping the bundle.

Which JSON shape does your library actually want

Nested JSON is the common denominator, but the runtimes differ in what they expect around it, and dropping a bundle into the wrong shape fails quietly rather than loudly.

Consumer Expects Placeholder syntax
vue-i18n Nested object per locale {name}
i18next Nested object, optionally per namespace {{name}}
React Intl Flat keys, ICU message syntax {name}
Laravel JSON Flat, source sentence as key :name

React Intl is the notable exception: it prefers flat keys and expects ICU MessageFormat, so nesting is unnecessary and Laravel-style pipe plurals will not work at all. Laravel's own JSON files are a different case again — they live at lang/{locale}.json, sit outside any locale directory, and use the full English sentence as the key rather than a dotted identifier. Nested output does not belong there.

Placeholder syntax almost always needs a rewrite pass. The converter leaves values untouched by design, since silently editing message bodies is how translations get corrupted, so a Laravel-sourced sheet full of :name tokens needs converting to whatever your runtime reads.

Rewriting Laravel placeholders for i18next

const toI18next = (node) =>
  typeof node === 'string'
    ? node.replace(/:(\w+)/g, '{{$1}}')
    : Object.fromEntries(
        Object.entries(node).map(([k, v]) => [k, toI18next(v)])
      )

Validating the sheet before it becomes a bundle

A returned spreadsheet is untrusted input that has passed through at least one spreadsheet application and one human. Four checks catch nearly everything, and all four are worth automating because reviewing a language you do not read is not a real review.

  1. 1 Key parity. Compare the returned key column against the file you sent. Added keys usually mean someone typed into the wrong row; missing keys mean a deleted row.
  2. 2 Placeholder integrity. Every token in the source must survive in the target. Translators localise enthusiastically, and a :name that became :nombre will render as literal text.
  3. 3 Plural form counts. Pipe-separated forms must match what the target locale needs. Spanish takes two, Polish three, Arabic six — check with the pluralization tester.
  4. 4 Encoding. Scan for é or ñ sequences, which mean a UTF-8 file was read as Latin-1 somewhere in the chain.

Then merge rather than overwrite. Between sending the sheet and receiving it your team almost certainly added keys that exist in the current bundle and not in the spreadsheet, and a straight replacement deletes them. Let the returned translations win for keys they cover and preserve everything they do not mention.

A merge that cannot lose keys

const merge = (current, incoming) => {
  const out = { ...current }
  for (const [key, value] of Object.entries(incoming)) {
    out[key] =
      value && typeof value === 'object' && typeof current[key] === 'object'
        ? merge(current[key], value)
        : value
  }
  return out
}

Reading the file the way the spreadsheet wrote it

Before any content check matters, the file has to parse into the rows you expect. CSV has no single authoritative dialect, and the application that produced your file made several decisions without asking.

Symptom Cause Fix
Everything in one column Semicolon-delimited export Re-export with commas, or set the delimiter explicitly
First key reads welcome UTF-8 byte order mark Strip the BOM before parsing the header
One row splits into two Unescaped newline inside a value Use a real CSV parser, never a string split
Cells show #NAME? Value began with = or - Format the sheet as Text and re-enter those rows
Empty trailing rows Blank cells exported below the data Skip rows with an empty key

Use a proper parser rather than splitting on commas. Every hand-rolled split eventually meets a translated sentence containing a comma, and the failure mode is a value truncated mid-sentence rather than an error you would notice.

Watch for type coercion in the key column too. A spreadsheet will happily turn a key like 1.10 into the number 1.1, and version-shaped or numeric keys silently change identity between export and import. Formatting the sheet as Text before any editing prevents the whole family of these problems.

Frequently asked questions

Does the converter keep :placeholders and plural pipes intact?

Yes. Values are treated as opaque strings, so Laravel placeholders like :name or :count and pipe-separated plural forms come through unchanged.

How is nesting handled?

Nested keys are flattened to dot notation in CSV (cart.empty) and re-expanded into nested structures when converting back.

Is my data uploaded or stored?

The conversion runs on our server but nothing is persisted: files are converted in memory and the response is returned immediately. For PHP input, only string, number and array literals are parsed — code is never executed.

Is there a size limit?

Yes, 200 KB per conversion, which covers even very large lang files. If you need bulk conversion across many locales, import your files into LangSyncer and export any format.

Converting files by hand gets old fast

Import your lang files into LangSyncer once, edit and translate them in a dashboard, and export any format — or skip files entirely with live translations.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content