Dictionary keys

Add, seed and use localised UI copy: the key vocabulary lives in code, the labels in Contentful.

2026-11-07

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.ts owns 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.yml seeds the Contentful entry (sync-dictionary.mjs push); run push-dictionary yourself 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.