Translations
Ship a policy in several languages: configure locales, split the frontmatter, and satisfy the contiguity rule the loader enforces.

A revision directory holds one MDX file per locale. This page covers what to configure, what goes in which file, and the one rule people trip on: once a locale exists, it can never disappear from a later revision.
Configure the locales
locales is the set the consumer serves, and every on-disk locale file must belong to it.
defaultLocale names the file that carries each revision's metadata, and that file must exist for
every revision. It defaults to "en" and must be a member of locales - the constructor throws
otherwise, before any filesystem access.
locales entries have to be expressible as filenames, and the grammar below admits only a
two-letter language with an optional two-letter region. A three-letter code (fil) or a script
subtag (zh-Hans) can never match a file, so a policy configured with one throws on every
revision.
import path from "node:path";
import { Policy } from "@daanvandenbergh/policykit";
export const termsPolicy = new Policy({
slug: "terms-of-service",
dir: path.join(process.cwd(), "policies", "terms-of-service"),
locales: ["en", "nl"],
defaultLocale: "en", // optional; "en" is the default
});
Split the frontmatter between the files
The default-locale file carries the revision metadata. Every other locale file carries only an
optional title. Any other key in a non-default file is a validation error - metadata duplicated
across locales is metadata that drifts.
---
effectiveFrom: "2026-08-12"
notice: notify
changeSummary: "Added the monthly fair-use limit."
title: "Terms of Service"
---
## Fair use
---
title: "Servicevoorwaarden"
---
## Redelijk gebruik
The first is 2026-08-01/en.mdx, the second 2026-08-01/nl.mdx. Translate the body fresh from
the new source text; a copied-forward translation validates green and lies.
Name the locale files
Filenames are matched against /^([a-z]{2}(?:-[A-Z]{2})?)\.mdx$/: two lowercase letters, an
optional hyphen plus two uppercase letters, then .mdx.
en.mdx valid
nl-NL.mdx valid
NL-nl.mdx invalid - the case is fixed, lowercase language then uppercase region
nl.md invalid - the extension is .mdx
fil.mdx invalid - three-letter languages are not expressible
zh-Hans.mdx invalid - script subtags are not expressible
A file that does not match is reported as an unexpected entry in the revision directory, and the error names it. Policy directories carries the same grammar for the whole layout.
Introduce a locale at one revision, then keep it
Translations must be contiguous from where they start, and need not cover the whole archive. A translation may appear for the first time at any revision - a translation minted today is not the historical document, so backfilling older revision directories is forbidden by the authoring rules. From that revision onward it must exist for every later revision. A forward gap throws:
Policy "terms-of-service": locale "nl" was introduced at revision 2026-08-01 but is missing
from revision 2026-09-15 - once introduced, a locale must exist for every later revision.
The reason is the archive. A Dutch reader bound by the 2026-09-15 revision must be able to read the text that binds them. A translation that vanishes mid-history leaves them with an English page for a version they already accepted.
This rule applies to translations only. The default locale is exempt from it because it can never be absent: every revision must carry its default-locale file, since that is where the frontmatter lives.
Keep disk and config in agreement
A locale file on disk that is not in the policy's configured locales is an error, not dead
content:
Policy "terms-of-service": "2026-08-01/de.mdx" is for locale "de", which is not in the
configured locales (en, nl) - a translation the consumer cannot serve is a misconfiguration,
not dead content.
Either the app is missing a locale it should serve, or somebody committed a translation nobody can reach. Both are worth a build failure. See validation for where that failure lands.
Render the right title
Titles do not fall back. content(revision, locale).title is that locale file's own title, and
PolicyRevision.title is the default locale's. A silent fallback would print an English heading
above Dutch text, so the choice stays yours:
// `locale` is the route param for the page being rendered.
const shown = termsPolicy.effective(new Date()) ?? termsPolicy.latest();
const title = termsPolicy.content(shown.revision, locale)?.title ?? shown.title;
List the locales a revision actually has
PolicyRevision.locales is the locales present at that revision, in the configured order,
which may be a subset of the policy's own locales. Read it per revision so a language switcher on
an old revision offers only the languages that revision was published in.
// nl was introduced at 2026-08-01
termsPolicy.revision("2026-07-07")?.locales; // ["en"]
termsPolicy.revision("2026-08-01")?.locales; // ["en", "nl"]
Policy.has(revision, locale) is the cheap yes/no when you only need to decide between rendering
and a 404. See rendering policies for the route shapes.