How to generate vue-i18n flat json translation files from an excel spreadsheet
- Step 1Lay out key + language columns — Keys in one column, each language in its own (
en,fr, or full names). vue-i18n is happy with flat dotted keys (home.title) or nested ones — decide your convention. Keep it all on the first sheet; only sheet 1 is read. - Step 2Drop the .xlsx or .csv onto the tool — SheetJS parses it in your browser. The first row becomes headers; each row after is one message. Nuxt and Quasar use the same message-object shape, so the same file works across all three.
- Step 3Set keyColumn and valueColumn — Type the exact headers — e.g.
keyColumn: key,valueColumn: en. To build the French file later, changevalueColumntofr. Matching is exact and case-sensitive. - Step 4Choose flat or nested — Leave
nestedoff for flat dotted keys (vue-i18n resolves these directly). Turnnestedon for a hierarchical message object. Both load fine in vue-i18n, Nuxt i18n, and Quasar — pick your team's convention. - Step 5Generate and place the locale file — Download the JSON (named after the value column) and put it where your setup expects messages —
locales/en.jsonfor Nuxt i18n, themessagesobject forcreateI18n, orsrc/i18n/en/index.jsre-export for Quasar. - Step 6Repeat per locale — Run once per
valueColumn(en,fr,de...). The identical key structure across files is what makes vue-i18n'sfallbackLocalefill gaps correctly.
vue-i18n message shapes from the same sheet
Flat and nested both resolve in vue-i18n. The lookup call is the same; only the JSON shape differs.
| Key cell | Flat (nested: false) | Nested (nested: true) | Lookup |
|---|---|---|---|
home.title | "home.title": "..." | { "home": { "title": "..." } } | $t('home.title') |
home.cta | "home.cta": "..." | { "home": { "cta": "..." } } | $t('home.cta') |
brand (no dot) | "brand": "..." | "brand": "..." | $t('brand') |
Where the generated file goes per framework
vue-i18n, Nuxt i18n, and Quasar all consume the same message object — only the file location/wiring differs.
| Framework | Typical location | How it's loaded |
|---|---|---|
| vue-i18n (Vite) | src/locales/en.json | imported into the messages of createI18n |
| Nuxt i18n | locales/en.json (or i18n/locales/) | referenced in i18n module config |
| Quasar i18n | src/i18n/en-US/index.js | re-exported as the locale's default object |
The three options + tier limits
Full option contract plus the per-file caps. There is no Vue-specific preset — flat/nested via the nested flag covers it.
| Option / tier | Value | Note |
|---|---|---|
keyColumn | default key | Cell values become keys (trimmed) |
valueColumn | default value | One language per run → one file |
nested | default false | false = flat dotted keys (vue-i18n default) |
| Free tier | 5 MB / 10,000 rows | Covers most single-locale files |
| Pro / Pro-media | 50 MB / 100k · 200 MB / 500k | For large master sheets |
| Developer | 500 MB / unlimited rows | No row cap |
Cookbook
Each block shows the spreadsheet and the vue-i18n message JSON. Flat is the default and works directly with $t(); switch nested on only if you prefer hierarchical messages.
Flat message file for vue-i18n
The default and simplest path: flat dotted keys that $t('home.title') resolves with no extra config. Run once per locale, changing valueColumn.
Sheet (first tab):
key | en | fr
-------------|-----------|------------
home.title | Welcome | Bienvenue
home.cta | Sign up | Inscription
Options: keyColumn=key, valueColumn=en, nested=false
Output → en.json:
{
"home.title": "Welcome",
"home.cta": "Sign up"
}
// $t('home.title') === 'Welcome'Nested message file (hierarchical convention)
Prefer a tree? Turn nested on. vue-i18n resolves $t('home.title') against the nested object just as well.
Sheet:
key | en
-------------|-----------
home.title | Welcome
home.cta | Sign up
nav.account | Account
Options: nested=true
Output → en.json:
{
"home": { "title": "Welcome", "cta": "Sign up" },
"nav": { "account": "Account" }
}Named-format placeholders survive
vue-i18n named formatting uses {name}. That's part of the value, written verbatim, so your interpolation keeps working.
Sheet:
key | en
---------------|----------------------------
welcome.user | Welcome, {name}!
cart.summary | {count} items, {total} total
Output → en.json (flat):
{
"welcome.user": "Welcome, {name}!",
"cart.summary": "{count} items, {total} total"
}
// $t('welcome.user', { name: 'Sam' }) → 'Welcome, Sam!'Linked messages pass through
vue-i18n linked messages reference another key with @:. The syntax lives in the value, so it's preserved exactly — useful for reusing a brand name across strings.
Sheet:
key | en
---------------|-------------------------
common.brand | Acme
footer.copy | © 2026 @:common.brand
Output → en.json:
{
"common.brand": "Acme",
"footer.copy": "© 2026 @:common.brand"
}
// renders: © 2026 AcmeNuxt i18n lazy-loaded locale files
Nuxt i18n's lazy mode loads one JSON per locale from a locales folder. Generate one file per language and drop them in; the keys match across files so fallbackLocale works.
Sheet: key | en | fr | de Run valueColumn=en → en.json Run valueColumn=fr → fr.json Run valueColumn=de → de.json Place in /locales: locales/en.json locales/fr.json locales/de.json Nuxt i18n config: lazy + langDir: 'locales/'
Edge cases and what actually happens
Flat keys but you expected nesting (or vice versa)
Both valid for vue-i18nvue-i18n resolves $t('home.title') against either a flat "home.title" key or a nested { home: { title } } object, so neither is 'wrong'. Pick one convention and keep it consistent across locales so diffs stay clean. The nested flag chooses.
A key is both a leaf and a parent (nested on)
Collision — last winsWith nested: true, home (a value) plus home.title (a child) collide on home. One overwrites the other by row order, so a lookup misses. Use flat output to sidestep it entirely, or rename so no key is both a leaf and a parent.
Duplicate key in the sheet
Last winsTwo home.title rows: the later value silently overwrites the earlier. Output key count drops below row count. If the app shows an unexpected string, check for a duplicate; dedupe with the duplicate-purge tool.
Linked-message target missing
Runtime — not the tool's jobA value like @:common.brand only works if common.brand also exists in the file. The tool writes the link verbatim; it doesn't verify the target. If a linked message renders as the literal @:..., the target key is missing from the generated file — add the row.
valueColumn header mismatch
Empty valuesvalueColumn: EN against a header en yields all empty strings — the messages object loads but every value is blank. Match the header exactly, including case.
Messages on a second worksheet
Not readOnly the first sheet is parsed. Put your vue-i18n messages on sheet 1, or export that sheet as CSV. A workbook with notes on sheet 1 and translations on sheet 2 yields the notes.
Pluralisation with the pipe syntax
Preservedvue-i18n plurals use | in the value (no items | one item | {count} items). That's part of the value and is written verbatim. The tool doesn't parse or validate it; vue-i18n handles the pipe at runtime.
Quote inside a translation
Escaped automaticallyA value like It's "on" is escaped correctly by JSON.stringify. This avoids the SyntaxError that hand-edited message files throw when a quote isn't escaped — the whole reason to generate rather than edit by hand.
Keys not alphabetised
By designOutput is in sheet row order; there's no sort. vue-i18n doesn't care, but sorted files diff better. Sort the spreadsheet rows first if you want ordered locale files.
File over the tier limit
RejectedA Free-tier file above 5 MB or 10,000 rows is rejected before processing. A single-locale message file is small; only a large multi-locale master would hit this. Split per locale or upgrade.
Frequently asked questions
Should I use flat or nested JSON for vue-i18n?
vue-i18n resolves $t('home.title') against both shapes, so either works. Flat (nested: false) is the simplest and is the default here — "home.title": "..." resolves directly. Use nested (nested: true) only if your team prefers a hierarchical message object. Keep whichever you pick consistent across all locales.
Does the same file work for Nuxt i18n and Quasar?
Yes. vue-i18n, Nuxt i18n, and Quasar i18n all consume the same message-object shape. Generate the JSON once and wire it where each framework expects it: createI18n messages for plain vue-i18n, the locales/ folder for Nuxt lazy-loading, or a re-export under src/i18n/ for Quasar.
Do my {name} placeholders survive?
Yes. vue-i18n named-format placeholders ({name}) are part of the value, which is written verbatim — only keys are trimmed. $t('welcome.user', { name: 'Sam' }) keeps working after generation. The tool doesn't touch or validate the { } syntax.
What about vue-i18n linked messages like @:common.brand?
They pass through verbatim because the @: link lives in the value. Just make sure the target key (common.brand) is also a row in the sheet so it ends up in the same file — the tool writes the link but doesn't verify the target exists.
Can it create en.json, fr.json, and de.json at once?
No — one value column per run, one file per run. Run the tool once per locale, changing valueColumn. Because the key column and nested setting stay the same, all locale files share an identical key structure, which is what vue-i18n's fallbackLocale needs.
How do vue-i18n plurals (the pipe syntax) come through?
Verbatim. A value like no items | one item | {count} items is written exactly as typed — the | is part of the value. vue-i18n interprets the pipe at runtime; the tool doesn't parse it. Put the full pluralised string in one cell.
Why are some translations blank in my message file?
Either those cells were empty in the sheet (the key is kept with ""), or your valueColumn doesn't match the header exactly so every value reads as empty. Check the header name and look for untranslated cells — search the output for : "".
Does it read all sheets in my workbook?
No, only the first sheet. There's no sheet selector. Keep vue-i18n messages on sheet 1, or save that sheet as a standalone CSV. A CSV is a single sheet, so the question doesn't arise.
Are the keys sorted?
No — they follow sheet row order. There's no sort option. vue-i18n doesn't require sorting, but if you want tidy git diffs across locales, sort the spreadsheet rows before generating.
Is it free and private?
Yes — free tier (5 MB / 10,000 rows per file), and processing is 100% browser-side via SheetJS. Your Vue/Nuxt/Quasar strings and the generated JSON never leave your machine, which is the safe choice for unreleased builds.
My sheet is wide with one column per locale — is that a problem?
No. Run the tool once per locale column. If you'd rather convert the wide layout to a long key/locale/value table first, the un-pivot tool reshapes it, then you generate from a single value column.
What other Excel-to-code tools fit a Vue/Nuxt project?
The same dev-bridge family includes excel-python-gen for Python data structures (handy for a backend), excel-trpc-router for a typed router, and excel-tailwind-export for rendering a sheet as a styled HTML table. All run browser-side from the first sheet.
Privacy first
Every JAD Excel tool runs entirely in your browser using SheetJS and ExcelJS. Your spreadsheets, formulas, and data never leave your device — verified by zero outbound network requests during processing.