How to generate next-intl json message files from an excel key-value spreadsheet
- Step 1Structure keys as next-intl namespaces — Write keys as
Namespace.keyor deeper (HomePage.hero.title) in the key column. These dots become the nested namespacesuseTranslations('HomePage')reads. Keep all rows on the first sheet — only sheet 1 is parsed. - Step 2Drop the .xlsx or .csv onto the tool — SheetJS parses it in your browser. The first row is the header; each later row is one message. Empty cells become empty strings, never null, so partially-translated rows stay valid.
- Step 3Set keyColumn and valueColumn — Type your exact headers — for example
keyColumn: key,valueColumn: en. To build the French file later you'll changevalueColumntofr. Header matching is exact and case-sensitive. - Step 4Turn on nested — next-intl wants nested objects, so enable
nested. NowHomePage.titleexpands into{ "HomePage": { "title": "..." } }instead of staying a flat dotted string. - Step 5Generate en.json, then save it to /messages — The tool emits one JSON file named after the value column (rename
en.jsonto match your locale code if needed) and place it in yourmessages/directory. The metrics show input rows vs output keys. - Step 6Repeat for every locale — Run again with
valueColumn: fr, thende, and so on. Because the key column andnestedflag don't change, everymessages/<locale>.jsonends up with the same namespace tree — exactly what next-intl's fallback and missing-key checks rely on.
Spreadsheet key → next-intl lookup
How a dotted key in your sheet maps to the nested JSON next-intl loads and the hook call that reads it. Requires nested: true.
| Key cell | Nested JSON (messages/en.json) | How you read it in a component |
|---|---|---|
HomePage.title | { "HomePage": { "title": "..." } } | t('title') after useTranslations('HomePage') |
HomePage.hero.cta | { "HomePage": { "hero": { "cta": "..." } } } | t('hero.cta') after useTranslations('HomePage') |
Nav.signin | { "Nav": { "signin": "..." } } | t('signin') after useTranslations('Nav') |
globalTitle (no dot) | { "globalTitle": "..." } | t('globalTitle') after useTranslations() |
The three options, mapped to next-intl needs
The full option contract. There is no next-intl preset and no namespace auto-grouping beyond what dotted keys + nested produce.
| Option | Default | Set it to | Why for next-intl |
|---|---|---|---|
keyColumn | key | Your namespaced-key header | Holds Namespace.key strings that become the tree |
valueColumn | value | The locale column (en, fr...) | One per run → one messages/<locale>.json per run |
nested | false | true | next-intl resolves namespaces from nested objects, not flat dotted keys |
Free vs paid limits for next-intl message files
A single-locale next-intl messages file is rarely large, but a master multi-locale sheet can be. Limits are per file, enforced before processing.
| Tier | Max file size | Max rows | Files per session |
|---|---|---|---|
| Free | 5 MB | 10,000 | 1 |
| Pro | 50 MB | 100,000 | 5 |
| Pro-media | 200 MB | 500,000 | 20 |
| Developer | 500 MB | unlimited | unlimited |
Cookbook
Each block shows the Excel rows on the left and the next-intl-ready messages JSON on the right. nested is on throughout, because next-intl resolves namespaces from nested objects.
Single namespace into messages/en.json
The canonical next-intl shape: a HomePage namespace with a few keys. Run once per locale, changing valueColumn, to get matching en/fr files.
Sheet (first tab):
key | en | fr
------------------|-----------------|------------------
HomePage.title | Welcome | Bienvenue
HomePage.subtitle | Get started | Commencez
Options: keyColumn=key, valueColumn=en, nested=true
Output → en.json (save as messages/en.json):
{
"HomePage": {
"title": "Welcome",
"subtitle": "Get started"
}
}Deep nesting for sub-sections
Three-level keys build a three-level tree. next-intl reads it with a namespace plus a dotted lookup inside the component.
Sheet:
key | en
-----------------------|------------------
Dashboard.header.title | Your dashboard
Dashboard.header.kpi | Active users
Dashboard.empty | Nothing yet
Output → en.json:
{
"Dashboard": {
"header": {
"title": "Your dashboard",
"kpi": "Active users"
},
"empty": "Nothing yet"
}
}
// useTranslations('Dashboard'); t('header.title')ICU plural messages survive untouched
next-intl uses ICU MessageFormat. The braces and pluralisation syntax are part of the VALUE, which is written verbatim — only keys are trimmed.
Sheet:
key | en
------------------|---------------------------------------------
Cart.items | {count, plural, one {# item} other {# items}}
Cart.greeting | Hi {name}!
Output → en.json:
{
"Cart": {
"items": "{count, plural, one {# item} other {# items}}",
"greeting": "Hi {name}!"
}
}
(ICU syntax preserved exactly)Two locales, identical structure
Running once per locale guarantees the same key tree in every file. That alignment is what makes next-intl's missing-key detection meaningful.
key | en | fr
---------------|----------|-----------
Nav.home | Home | Accueil
Nav.account | Account | Compte
Run 1 valueColumn=en → en.json
Run 2 valueColumn=fr → fr.json
en.json: { "Nav": { "home": "Home", "account": "Account" } }
fr.json: { "Nav": { "home": "Accueil","account": "Compte" } }Untranslated cells become empty strings
A row with a key but a blank value writes "". This is handy: next-intl will fall back, and you can grep the file for : "" to find every string still awaiting translation.
Sheet:
key | fr
---------------|-----------
Nav.home | Accueil
Nav.beta | ← not translated yet
Output → fr.json:
{
"Nav": {
"home": "Accueil",
"beta": ""
}
}
(grep ': ""' to list untranslated keys)Edge cases and what actually happens
nested left off for next-intl
Wrong shapeWith nested: false, HomePage.title stays a flat key "HomePage.title". next-intl's useTranslations('HomePage') then can't find a HomePage namespace object and the lookup misses. For next-intl you almost always want nested: true.
A namespace name is also used as a leaf key
Collision — last winsIf you have both Settings (a value) and Settings.theme (a child), they collide on the Settings path under nesting. One overwrites the other by row order, so a whole namespace can vanish. Keep namespace names distinct from leaf keys.
Header typo: valueColumn doesn't match
Empty valuesSet valueColumn: EN when the header is en and every value reads as empty string — you get a correctly-shaped tree full of "". next-intl will render blanks. Match the header text exactly, including case.
Translations on a second worksheet
Not readOnly the first sheet is parsed. A workbook with a glossary on sheet 1 and messages on sheet 2 yields the glossary. Move messages to the first sheet or export that sheet as CSV.
Duplicate namespaced key
Last winsIf HomePage.title appears twice, the later row's value overwrites the earlier one silently. Output key count drops below row count. Dedupe with the duplicate-purge tool if duplicates were unintended.
Curly quotes pasted from a doc
Preserved as-isTranslators pasting from Google Docs often bring smart quotes (“ ”) into values. These are kept verbatim — valid in JSON and fine for display. The tool doesn't normalise them; if you want straight quotes, clean the sheet first.
File over the tier limit
RejectedA Free-tier upload above 5 MB or 10,000 rows is rejected before any JSON is produced. A focused single-locale extract usually fits easily; a giant master sheet may need Pro or a per-locale split.
ICU braces look broken in the JSON
By designAn ICU value with many braces looks dense in the output, but it's exactly what you typed — escaped only where JSON requires. next-intl parses it at runtime. Don't hand-edit the braces out; that breaks pluralisation.
Numeric-looking message (e.g. version string)
StringifiedA value cell like 2.0 is read and written as the string "2.0" (values are always strings here). For next-intl message text that's correct — messages are strings, not numbers.
Empty-key rows between namespaces
SkippedBlank rows used to visually separate HomePage from Dashboard in the sheet are skipped because their key is empty. They don't appear in the JSON, so your messages file stays clean.
Frequently asked questions
Does the output drop straight into my next-intl messages folder?
Yes, with nested: true. The generated JSON is a nested namespace object, the shape next-intl loads from messages/<locale>.json. Rename the downloaded file to your locale code (e.g. en.json) and place it in messages/. The structure matches what useTranslations('Namespace') resolves.
Why do I need nested on for next-intl?
next-intl resolves namespaces from nested objects — useTranslations('HomePage') looks for a HomePage object. With nested: false, HomePage.title stays a flat string key and the namespace lookup misses. Turn nested on so dotted keys become the namespace tree.
Can it produce en.json and fr.json in one go?
No — one value column per run, one file per run. Run the tool once per locale, changing valueColumn each time (en, then fr). Because the key column and nesting stay the same, every file shares the same namespace structure, which is what next-intl's fallback logic expects.
Will my ICU plural and select messages survive?
Yes. The value is written verbatim — only the key is trimmed. {count, plural, one {# item} other {# items}} passes through unchanged. The tool doesn't validate ICU syntax; next-intl parses it at runtime, so make sure the spreadsheet text is correct.
I'm getting 'messages.json is not valid JSON' — does this fix it?
Generating instead of hand-editing eliminates the usual culprits: unescaped quotes, trailing commas, missing braces. JSON.stringify escapes everything correctly. If the build still complains, the problem is elsewhere (wrong file path, a stray byte-order issue, or a manually edited file you forgot to regenerate).
How do I structure deep namespaces like HomePage.hero.title?
Just write the full dotted path in the key column. With nested on, each dot becomes a level, so HomePage.hero.title builds { HomePage: { hero: { title: ... } } }. In the component you call useTranslations('HomePage') then t('hero.title').
Does it read every sheet in the workbook?
No, only the first sheet. There's no sheet selector. Keep your next-intl messages on sheet 1, or export just that sheet as CSV before uploading. A CSV is a single sheet, which removes the ambiguity.
What happens to untranslated rows?
A row with a key but an empty value writes "key": "". The key stays so your structure is complete; next-intl falls back to the default locale. You can search the file for : "" to list every string still needing translation.
Are the keys sorted?
No. Keys appear in sheet order. There's no sort option. If you want sorted message files for cleaner git diffs across locales, sort the spreadsheet rows before generating, or sort the JSON in your editor afterwards.
Is it free and private?
Yes — free tier, 5 MB / 10,000 rows per file, and everything runs in your browser via SheetJS. Your route copy, marketing strings, and the generated messages JSON never leave your machine. That's the safe path for unreleased Next.js pages.
My sheet is one wide table with many locale columns — is that fine?
Yes. Run the tool once per locale column. If you'd rather reshape the wide table into a long key/locale/value layout first, the un-pivot tool does that, after which you generate from a single value column.
What other Excel-to-code tools pair well with this for a Next.js app?
For the same Next.js codebase, excel-trpc-router turns a sheet into a typed tRPC router, excel-python-gen emits Python data structures for a backend, and excel-tailwind-export renders 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.