Short version: eloqnt doesn't support arrays in messages files. That's by design — arrays carry real drawbacks for translation, spelled out below. If you want them anyway, a custom codec handles them completely, and there's a working example at the end of this page.
A messages file like this:
{
"greeting": "Hello",
"verses": ["Roses are red", "Violets are blue"]
}stops the command:
✗ Message `verses` is an array, and arrays can't be translated (MALFORMED_CATALOG)
messages/en.json
→ Give each item its own key, or configure a custom codec to read arrays.
https://studio.eloqnt.dev/docs/configuration#format
This is a hard error, not a warning, and the value has to be dealt with before eloqnt will run. Reading a messages file is all-or-nothing: a value we can't interpret means we can't be sure what the file contains, and quietly ignoring part of it is how untranslated strings go missing without anyone noticing.
The element count is a source-language fact. A verse that's three lines in English might be two in German, where two clauses merge naturally, or four elsewhere. An array makes length a positional contract shared across every locale, and a translator has no way to say "these two lines become one here" without breaking it.
Position isn't identity. Insert an element at the top and every element below it shifts index. Translation memory, review history, and your diffs all key off the identifier, so a one-line insertion looks like every subsequent line was rewritten — and nothing already translated and reviewed survives that cleanly.
There's nowhere to attach context. Each message goes to the engine with its description and its source references, and that context is most of what separates a good translation from a literal one. An array has one key and its elements have none, so every element would arrive anonymous.
ICU can't help. A plural inside an array element still works textually, but the thing an array usually stands in for — a list whose length varies by locale — isn't expressible. Where you have a genuine list of runtime values, Intl.ListFormat is the right tool, and that's a different feature from storing an array.
Worth knowing if you're reaching for t.raw, because the ground under it is moving.
Arrays aren't part of next-intl's message type. AbstractIntlMessages is {[id: string]: AbstractIntlMessages | string} — there's no array branch. Arrays work at runtime because a JS array is structurally an object with numeric keys, and because t.raw returns any. The docs sanction it in exactly one sentence, in the raw-messages section: "The value of a raw message can be any valid JSON value: strings, booleans, objects and arrays."
The documented pattern is keyed objects, and the stated reason is validation. The arrays-of-messages section maps message keys to an array inside the component and closes with: "This approach ensures that you can use ICU features while also enabling static validation of messages." Where the number of items varies by locale, it suggests useMessages plus Object.keys rather than an array.
The position has been consistent for years:
- #64 (2021) — "Support arrays for dynamic rendering and component reuse", closed.
- #418 (2023) — "The trick is to move the array to your React component". When a commenter proposed
t.raw()with an array, the reply named the downsides: no ICU features, and incompatibility with validation tooling. - #834 (2024) — dynamically-sized lists from a CMS, answered with "use the CMS's own localization".
- #1434 and #1528 (2024) — returning translated objects and arrays, and iterating a plain JSON array. Both closed.
- #1878 (2025) — someone asking for the rationale to be written down in one place. It went stale without an answer, which is a good part of why this page exists.
The newer APIs already exclude it. The extraction docs state: "The one exception is t.raw, this feature is not intended to be used with message extraction." That's shipped behavior, not a plan.
And t.raw itself may not survive v5. The v5 umbrella issue lists "Investigate if we could get rid of t.raw. It leads users to toward bad patterns that might not work with future optimizations." The reasoning is that t.raw was added four years ago for rendering raw HTML, became commonly used for array-like data, and works poorly with message extraction, automatic tree-shaking of messages, ahead-of-time compilation, and message validation — with explicit alternatives suggested for each typical use: explicit keys for array data, hardcoding or a CMS for non-translation data, and MDX or a CMS for raw HTML.
So building on t.raw for arrays is worth weighing on its own, independently of anything eloqnt does.
i18next is the strongest case for arrays. They're documented and first-class: returnObjects: true returns arrays and objects, joinArrays concatenates them with a separator, and elements are addressable by dot-index (t('arrayOfObjects.0.name')). Worth knowing that the tooling is weaker than the runtime, though — i18next-parser doesn't support returnObjects and has a long-standing report of writing such entries back as an empty string. Even where arrays are embraced, the write path is where they break.
vue-i18n supports them through a separate API. Array-structured locale messages need tm() to retrieve and rt() to resolve, and the docs warn "There is no internal fallback with rt". Same shape as t.raw: reachable only by stepping outside the normal translation function.
Outside JS, arrays exist but aren't the translation unit. Android has <string-array>, but its docs steer anything language-dependent toward quantity strings. Gettext has no array concept at all — msgstr[0], msgstr[1] are plural forms bound to the locale's nplurals, not a list.
Round-tripping arrays through translation platforms is genuinely fragile. Lokalise exports sequential keys back as an array only if the key for position 0 is present, and if that one is missing, "the rest of the keys will be turned into an object, using the position number as the key name". A translator deleting the first element silently changes your file's shape.
The lowest-friction fix, and the one next-intl recommends:
{
"greeting": "Hello",
"verses": {
"first": "Roses are red",
"second": "Violets are blue"
}
}const t = useTranslations();
const verses = [t('verses.first'), t('verses.second')];Every line becomes a real message: ICU support, its own description, individually reviewable. If the count varies by locale, useMessages plus Object.keys covers it without arrays.
If the lines belong together — a verse, a poem, a multi-paragraph block — this is usually the better answer, and it's the one case where the count really can differ per locale:
{
"verses": "<p>Roses are red</p><p>Violets are blue</p>"
}const t = useTranslations();
return <div>{t.rich('verses', {p: (chunks) => <p>{chunks}</p>})}</div>;The translator decides how many <p> blocks the locale needs. A German translation with three paragraphs against an English source with two is fine — it stays one message, one unit of context for the engine, one thing to review, and eloqnt lint reports no issues on the mismatch. ICU features work inside it, and unlike t.raw, t.rich is a first-class API that message extraction and validation both understand.
If your project needs arrays on disk, a codec owns both reading and writing, so it can flatten arrays into keyed messages on the way in and rebuild them on the way out. eloqnt never sees an array, and your file never stops being one.
Point your config at it:
import {defineConfig} from '@eloqnt/cli';
export default defineConfig({
messages: {
path: './messages',
locales: 'infer',
sourceLocale: 'en',
format: {codec: './.eloqnt/ArrayJSONCodec.ts', extension: '.json'}
}
});And the codec itself:
import {defineCodec} from '@eloqnt/cli';
type Json = string | Array<Json> | {[key: string]: Json};
type Message = {
id: string;
message: string;
description: Array<string>;
references: [];
};
export default defineCodec(() => {
// Which paths were arrays on the way in, so encode can rebuild them.
// Retained per instance: eloqnt decodes and encodes a file with the same one.
const arrayPaths = new Set<string>();
function walk(value: Json, path: string, out: Array<Message>): void {
if (typeof value === 'string') {
out.push({id: path, message: value, description: [], references: []});
} else if (Array.isArray(value)) {
arrayPaths.add(path);
value.forEach((item, index) => walk(item, `${path}.${index}`, out));
} else {
for (const [key, child] of Object.entries(value)) {
walk(child, path ? `${path}.${key}` : key, out);
}
}
}
function rebuild(node: Record<string, unknown>, path: string): unknown {
for (const [key, value] of Object.entries(node)) {
if (value && typeof value === 'object') {
node[key] = rebuild(
value as Record<string, unknown>,
path ? `${path}.${key}` : key
);
}
}
if (!arrayPaths.has(path)) return node;
return Object.keys(node)
.sort((a, b) => Number(a) - Number(b))
.map((key) => node[key]);
}
return {
decode(content) {
const out: Array<Message> = [];
walk(JSON.parse(content) as Json, '', out);
return out;
},
encode(messages) {
const root: Record<string, unknown> = {};
for (const message of messages) {
const keys = message.id.split('.');
let current = root;
for (const key of keys.slice(0, -1)) {
if (typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
current[keys[keys.length - 1]] = message.message;
}
return `${JSON.stringify(rebuild(root, ''), null, 2)}\n`;
}
};
});With that in place, the file from the top of this page lints as ordinary messages:
messages/de.json
│
│ "verses.0": "Roses are red"
│ ─┬────────
│ ╰─ Missing translation in de.json (missing-translation)
│
│ "verses.1": "Violets are blue"
│ ─┬────────
│ ╰─ Missing translation in de.json (missing-translation)
! 2 warnings
→ Run `eloqnt translate` to fill in 2 missing translations
Encoding the decoded messages reproduces the original file byte for byte, arrays intact.
Two things to know before adopting it. Locales have to agree on element count — a target with more elements than the source reports a superfluous-key error, and fewer reports a missing translation, because there's no way to tell a genuinely missing line from a deliberately shorter one. And an object key that happens to be "0" is indistinguishable from an array index after flattening, so this codec turns such an object back into an array. Neither matters for a fixed block of lines, which is what this is for.