Make missing translations fail your Astro build
I build sites for clients who need English and Spanish. The bug I kept shipping was always the same one: someone adds a string in English, nobody translates it, and a Spanish-speaking visitor finds the gap weeks later. I
I build sites for clients who need English and Spanish. The bug I kept
shipping was always the same one: someone adds a string in English, nobody
translates it, and a Spanish-speaking visitor finds the gap weeks later. It is
almost always the second language that quietly breaks.
Astro's i18n routing gives you / and /es/, hreflang and locale-aware
URLs. It does not tell you that your Spanish page is missing a testimonial,
or that the Spanish pricing table still says $29 after you raised it to $39.
So I made drift a build error. Three checks, plain Astro and TypeScript, no
libraries.
1. Typed copy: a missing key is a compile error
Put every piece of one-off text β nav labels, hero, CTAs, meta β in two
TypeScript files, and derive the type from the English one:
// src/content/copy.en.ts
export const COPY_EN = {
hero: {
title: 'Launch in English and Spanish on day one.',
cta: 'Get early access',
},
features: [
{ title: 'Two languages, one deploy', body: 'β¦' },
// β¦
],
};
export type SiteCopy = typeof COPY_EN;
// src/content/copy.es.ts
import type { SiteCopy } from './copy.en';
export const COPY_ES = {
hero: {
title: 'Lanza en espaΓ±ol e inglΓ©s desde el primer dΓa.',
cta: 'Quiero acceso anticipado',
},
features: [
{ title: 'Dos idiomas, un despliegue', body: 'β¦' },
// β¦
],
} satisfies SiteCopy;
satisfies is the important part. Delete hero.cta from the Spanish file and
astro check fails β your editor flags it before you even save. Add a key
that English doesn't have and that fails too, so the two files can't quietly
grow apart in either direction.
2. Array parity: what satisfies can't see
satisfies checks shape, not length. Six English features and five Spanish
ones both satisfy { title: string; body: string }[]. That's the most common
drift of all: someone adds a testimonial in one language.
A small recursive walk catches it:
// src/lib/copy-parity.ts
export function assertCopyParity(en: unknown, es: unknown, path = ''): void {
if (Array.isArray(en) && Array.isArray(es)) {
if (en.length !== es.length) {
throw new Error(
`copy: "${path}" has ${en.length} entries in English but ${es.length} in Spanish β ` +
`arrays must have the same length in both locales.`,
);
}
en.forEach((item, i) => assertCopyParity(item, es[i], `${path}[${i}]`));
return;
}
if (en && typeof en === 'object' && es && typeof es === 'object') {
for (const key of Object.keys(en)) {
assertCopyParity(
(en as Record<string, unknown>)[key],
(es as Record<string, unknown>)[key],
path ? `${path}.${key}` : key,
);
}
}
}
Call it at module load, in the one file every page imports to get copy,
not inside a page:
// src/content/copy.ts
import { assertCopyParity } from '../lib/copy-parity';
import { COPY_EN, type SiteCopy } from './copy.en';
import { COPY_ES } from './copy.es';
assertCopyParity(COPY_EN, COPY_ES); // runs on every build
export function copyFor(locale: 'en' | 'es'): SiteCopy {
return locale === 'es' ? COPY_ES : COPY_EN;
}
Because it runs when the module loads, it fires on every astro build, no
matter which page happens to import copyFor first. The failure is
specific:
Error: copy: "features" has 6 entries in English but 5 in Spanish β
arrays must have the same length in both locales.
3. Content-collection pairs: same entry, two files
Longer content β pricing plans, FAQ answers, practice areas β lives in
Markdown content collections as file pairs:
src/content/plans/
starter.md starter.es.md
pro.md pro.es.md
scale.md scale.es.md
Two things can drift here. A file can be missing its counterpart, and a field
that must be identical in both languages β the price, the sort order β can
change in one file only. That second one is nasty, because you never see both
languages on one screen in code review.
Pair the entries by filename, then compare the fields that must not differ:
// src/lib/pairs.ts
export function pairByLocale<T>(entries: { id: string; data: T }[], collection: string) {
const en = new Map<string, T>();
const es = new Map<string, T>();
for (const { id, data } of entries) {
if (id.endsWith('.es')) es.set(id.slice(0, -3), data);
else en.set(id, data);
}
for (const key of en.keys())
if (!es.has(key))
throw new Error(`${collection}: "${key}" has no Spanish counterpart β expected src/content/${collection}/${key}.es.md to exist.`);
for (const key of es.keys())
if (!en.has(key))
throw new Error(`${collection}: "${key}.es" has no English counterpart β expected src/content/${collection}/${key}.md to exist.`);
return [...en].map(([key, data]) => ({ key, en: data, es: es.get(key)! }));
}
export function assertPairedFields<T>(
pairs: { key: string; en: T; es: T }[],
fields: readonly (keyof T)[],
collection: string,
) {
for (const pair of pairs)
for (const field of fields)
if (pair.en[field] !== pair.es[field])
throw new Error(
`${collection}: "${pair.key}" field "${String(field)}" differs across locales β ` +
`EN=${JSON.stringify(pair.en[field])}, ES=${JSON.stringify(pair.es[field])}.`,
);
}
// src/lib/content.ts
import { getCollection } from 'astro:content';
import { assertPairedFields, pairByLocale } from './pairs';
// Everything that is not translated copy.
const PAIRED_PLAN_FIELDS = ['priceMonthly', 'priceYearly', 'featured', 'order'] as const;
export async function getPlanPairs() {
const pairs = pairByLocale(await getCollection('plans'), 'plans');
assertPairedFields(pairs, PAIRED_PLAN_FIELDS, 'plans');
return pairs.sort((a, b) => a.en.order - b.en.order);
}
Now raising the Pro price in pro.md and forgetting pro.es.md gives you:
Error: plans: "pro" field "priceMonthly" differs across locales β EN=39, ES=29.
The gotcha that silently breaks pairing
This one cost me an afternoon. Astro's glob() loader generates entry ids by
slugifying the file path, and slugifying strips the dot: pro.es.md
becomes the id proes. The .es suffix is gone, so the pairing code thinks
proes is a separate English entry with no Spanish counterpart.
Derive the id from the filename yourself:
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
const fromFilename = ({ entry }: { entry: string }) => entry.replace(/\.md$/, '');
const plans = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/plans', generateId: fromFilename }),
// schema: β¦
});
The same trap applies if your frontmatter has a slug field: the default
generateId uses it, so an EN/ES pair that shares a slug collapses into one
entry.
What this buys you
-
astro checkfails on a missing key. -
astro buildfails on a missing array item, a missing counterpart file, or a value that drifted between languages. - Every error names the exact path or file to fix.
- None of it ships to the browser β it all runs at build time.
It's under 100 lines in total, and it works for any number of locales if
you generalise the en/es pair to a map.
I packaged this approach into two bilingual Astro 7 + Tailwind 4 templates:
Despega, a SaaS landing page, and
Despacho, for law firms and
professional services. They're paid ($79 / $59, one-time, unlimited
projects) at templates.bravelytech.com,
but everything above is yours to use in any project.
How do you keep locales in sync on your multilingual sites? I'd like to hear
what other people do.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.