Dictionary keys
Add, seed and use localised UI copy: the key vocabulary lives in code, the labels in Contentful.
The vocabulary
The dictionary is the site's store of localised UI copy — the short strings a component renders that are not editorial content (button labels, "Load More", "minute read"). Its ownership is split between code and Contentful:
packages/model/src/dictionary/keys.tsowns the key vocabulary and its default copy (worded in en-GB). It is hand-curated and reviewed in PRs.- Contentful owns the localised labels editors maintain per locale.
The two never fight, because they own different things. Code owns which keys exist and what the English fallback says; editors own the translations.
keys.ts
keys.ts is a single as const object mapping each key to its default copy:
// packages/model/src/dictionary/keys.ts
export const DICTIONARY_KEYS = {
"appMoreMenu.action.myAccount": "My Account",
"article.Label.relatedContent": "Related Content",
"appVersion.update.description": "Would you like to update to version {{version}} ?",
"common.action.signIn": "Sign In",
// …~1,540 keys: seeded 2026-09-01 from 766 template entries, grown since
} as const;
Keys are dotted namespaces (appMoreMenu.action.myAccount), and case-sensitive —
the seeded keys keep their original casing (article.Label.relatedContent). The as const
annotation is load-bearing: it is what lets the compiler read each default label
as a literal type and derive typed interpolation params from it (see
Consuming).
Editing rules, from the file's own header:
- Add a key — add a line. On merge to
develop,model-apply-develop.ymlseeds the Contentful entry (sync-dictionary.mjs push); runpush-dictionaryyourself only for another environment. - Remove a key — delete the line. The Contentful entry becomes a reported orphan; removing it from the space is a separate editorial decision, never something the tooling does for you.
Prettier unquotes identifier-safe keys, and the sync parser accepts both quoted
and unquoted forms — keep each entry as one parseable key: "label" pair per
line so the seed/pull scripts can read it.
What is generated
The per-locale label maps are generated and committed — never hand-edited — but deliberately NOT drift-gated: a label is editorial copy Contentful owns, so gating it made every editor's edit a red build on whatever merge came next (Adding and syncing).
packages/model/src/__generated__/dictionary/
├── en-GB.ts # generated by sync-dictionary
├── en-US.ts
├── fr-FR.ts
├── it-IT.ts
└── index.ts # labelsByLocale, DICTIONARY_LOCALES, DEFAULT_DICTIONARY_LOCALE
keys.ts is the vocabulary; these maps are a snapshot of the labels pulled from
Contentful, used when the live read fails. What makes a missing or mistyped key a
compile-time error instead of a runtime miss is keys.ts being as const.
The site's default locale is en-US (DEFAULT_DICTIONARY_LOCALE), the
space's only locale on staging and master. A label seeded in en-US beats the
keys.ts default, so the site's copy can legitimately differ from keys.ts —
check the en-US entry in Contentful before treating a mismatch as a bug.
Adding and syncing
Two scripts move copy between keys.ts and Contentful, and a drift gate holds
them to account. Both are thin wrappers over scripts/sync-dictionary.mjs:
| Command | Runs | Direction |
|---|---|---|
pnpm --filter @v4/model push-dictionary |
sync-dictionary.mjs push |
code → Contentful |
pnpm --filter @v4/model sync-dictionary |
sync-dictionary.mjs pull |
Contentful → code |
push-dictionary — seed new entries
push-dictionary seeds a Contentful entry for any key in keys.ts that does
not have one yet: it creates and publishes the entry with the key and its
default label from keys.ts.
The critical guarantee is what it does not do. Push never updates or deletes
an existing entry — labels belong to editors, so an entry that already exists is
left exactly as the editors left it, translations and all. Keys present in
keys.ts with no Contentful entry are created; entries in Contentful with no key
in keys.ts are reported as orphans, not removed.
So the add-a-key flow is: add the line to keys.ts (reviewed in a PR) and merge.
model-apply-develop.yml runs on a push to develop that touches keys.ts and
seeds the new entries there (sync-dictionary.mjs push). For any other
environment, run push-dictionary against it yourself. Editors take it from
there.
sync-dictionary — pull the labels
sync-dictionary (pull) regenerates src/__generated__/dictionary/<locale>.ts
from the current Contentful labels. Run it whenever editors have translated or
changed copy, and after adding a locale.
The pull also warns on placeholder drift — a translation whose placeholders differ from the default copy's (four live cases were found on the first pull). That is a signal to reconcile the translation, not a hard failure.
The drift gate
keys.ts is committed, and so are the generated per-locale maps — but only
keys.ts and the schema side are gated. CI holds the model to account through
the drift command:
pnpm --filter @v4/model drift
drift runs sync-schema, then codegen, then git diff --exit-code over
schema.graphql and src/__generated__ excluding
src/__generated__/dictionary. It does not pull the dictionary at all.
The labels are deliberately not gated (29 Sep 2026). A label is editorial
copy Contentful owns, so gating it turned an editor's label edit into a red build
on whatever merge happened to be next — and because the pull read the management
API, an unpublished draft could redden CI for a value nobody could see. The
structural half is locked in the CMS instead: key and contentfulTitle are
disabled, so an editor cannot orphan a key.
The generated maps are now an offline FALLBACK rather than the source — the site
reads labels from Contentful at runtime (getDictionary). Refresh them with the
pull when you want the fallback current; nothing fails if you do not.
This is the local pre-push check; it hits the network, so pnpm verify does not
include it — run drift yourself whenever you have touched the model or expect
label changes.
When a label is empty
Every key always resolves: createDictionary falls back locale → default locale (en-US here)
→ DICTIONARY_KEYS (the keys.ts defaults). A key with no label in Contentful therefore shows
its default copy rather than failing, and a seeded en-US label beats that default — so copy can
differ from the design with nothing wrong in code.
Consuming
Apps and route factories read copy through getDictionary, imported from
@v4/model/dictionary — never from the generated codegen paths directly.
getDictionary — the runtime read
import { getDictionary } from "@v4/model/dictionary";
const t = await getDictionary(locale); // locale defaults to DEFAULT_DICTIONARY_LOCALE (en-US here)
const label = t("common.action.loadMore"); // "Load More"
It is async because labels are editorial copy that Contentful owns, read per
request through the app's cached client and tagged content:dictionaryKey. An
editor publishes a label and the next render has it — no build, no deploy.
It is server-only, hence the separate subpath: importing it from the @v4/model
barrel would break any client component importing that barrel.
The app configures the client once, beside where it builds it:
import { configureDictionary } from "@v4/model/dictionary";
configureDictionary({ client: contentClient });
The site does it in apps/web/src/lib/content.ts. Configured rather than
passed, for the same reason configureMedia is — copy is read from places that
are handed no Contentful client of their own (layouts, route handlers, helpers),
and none of them should need one threaded through. Unconfigured is not an error:
it falls back to the snapshot.
createDictionary — the synchronous fallback
createDictionary(locale) still exists and still returns a typed t, reading
the committed snapshot. It is what tests use, and what getDictionary falls back
to if the fetch fails — copy is never worth a 500.
Both return a typed function t. Resolution order per
key is requested locale → default locale (en-US here) → the vocabulary
default in keys.ts, so every key resolves to something.
With getDictionary the labels come from Contentful rather than the snapshot:
Contentful in the requested locale → Contentful in the default locale →
keys.ts. The committed snapshot is not in that chain at all — it is used only
when the fetch throws, or when nothing has been configured, in which case
resolution is the synchronous chain above. A key outside the vocabulary is
a type error, not a runtime miss.
The locale argument is typed against DictionaryLocale (the union of
DICTIONARY_LOCALES), so a locale the environment does not serve will not
compile.
Typed interpolation
Because keys.ts is as const, the params a key needs are parsed from its own
default copy at the type level. A key with placeholders requires a matching
params object; a key without one refuses it:
// default copy: "Would you like to update to version {{version}} ?"
t("appVersion.update.description", { version: "4.2.0" }); // ok
t("appVersion.update.description"); // error — params required
t("common.action.signIn"); // ok — no placeholders
t("common.action.signIn", { anything: "x" }); // error — refuses params
Param values may be strings or numbers. Both {name} and the legacy {{name}}
placeholder forms are supported — both appear in the seeded copy.
Scoped access
t.scope(prefix) returns a namespaced accessor whose keys are the suffixes under
that prefix. The params typing carries through unchanged:
const menu = t.scope("appMoreMenu.action");
menu("myAccount"); // === t("appMoreMenu.action.myAccount")
Server-first
Resolve copy in Server Components and transforms and pass the resulting strings as props to client components — this keeps the ~40 KB per-locale maps out of client bundles. See Server-first resolution in the localisation guide for the full reasoning.
Promoting labels between environments
Translations that editors have completed in one environment are copied forward
with the promote subcommand:
node packages/model/scripts/sync-dictionary.mjs promote --from=<env> --to=<env>
Promote copies labels for vocabulary keys only: entries missing in the
target are created and published, labels that differ are updated to the source
values and republished, and everything else is left untouched. Both --from and
--to are required and must be different environments. Protected environments
are refused as a target outside CI, the same guard the migration runner uses. The
/model-dictionary skill walks this flow end to end.