Yesterday I wrote about the
converter behind our British to American spelling switch:
172 stems, 105 one-off words, and a test that sweeps the content library for
anything it has not been taught. That post was about the words. This one is
about the plumbing, which took longer.
A reader preference added after the fact is a good way to find out how many
places your copy lives in. Here is where this one had to reach.
1. The server, before the first byte
The choice is a cookie, read in a server component, so the converted text is in
the first render rather than swapped in afterwards:
export async function getExerciseSpelling(): Promise<ExerciseSpelling> {
const cookieStore = await cookies();
const chosen = parseExerciseSpelling(cookieStore.get(EXERCISE_SPELLING_COOKIE)?.value);
if (chosen) return chosen;
const country =
(await headers()).get('x-vercel-ip-country') ??
cookieStore.get(COUNTRY_COOKIE)?.value ??
null;
return country?.toUpperCase() === 'US' ? 'en-US' : 'en-GB';
}
Two things worth pulling out. First, the default is geographic: a visitor in the
US starts on American spelling, everyone else starts on British, which is the
language the content is written in. Second, that country comes from exactly the
same header, with exactly the same cookie fallback, that already decides which
currency to show you. One source of "where is this person", two features reading
it. The alternative, a second country resolver, is the kind of thing that is
correct for a year and then disagrees with itself.
parseExerciseSpelling returning null rather than a default is deliberate.
The caller needs to distinguish "the user chose British" from "the user chose
nothing", because only the second case should be overridden by geography.
2. The content, once per exercise
Converting an exercise means walking its brief, its documents, its inbox and
every item. Doing that per request for static content is waste, so the result is
cached against the source object:
const americanExercises = new WeakMap<Exercise, Exercise>();
export function respellExercise(exercise: Exercise, spelling: ExerciseSpelling): Exercise {
if (spelling === 'en-GB') return exercise;
const cached = americanExercises.get(exercise);
if (cached) return cached;
// ...convert, store, return
}
A WeakMap keyed on the exercise object is the right shape here because the
library is module-level and static: the key lives as long as the process, the
value never goes stale, and nothing needs invalidating. en-GB returns the
identical object, which matters for referential equality in React further down.
The other half of that function is the list of what it does not touch. IDs,
keys, the sector, the layout and the kind pass through untouched, because the
server works against the unconverted definition and a respelled identifier
would simply fail to match. There is a test that walks every exercise and
asserts the non-text fields are byte-identical in both spellings, which is a
cheap way to stop a careless map over Object.entries from someday converting
a field that is not prose.
3. The page metadata
return {
title: respell(`${exercise.title} - Assessment Centre Practice`, spelling),
description: respell(exercise.description, spelling),
robots: { index: false, follow: false },
};
These pages are noindex, so no crawler will ever read that title. The browser
tab will. "Assessment Centre Practice" sitting above a page that otherwise says
"center" is exactly the sort of seam that makes a product feel assembled rather
than built.
The page itself resolves the preference alongside the plan rather than after it:
const [planInfo, spelling] = await Promise.all([getUserPlan(user.id), getExerciseSpelling()]);
const exercise = respellExercise(source, spelling);
getExerciseSpelling is cheap, but it is still an await on request headers,
and there is no reason for it to queue behind a database read.
4. The screen you get when it is locked
An exercise you have not unlocked renders a short explanation instead of the
exercise. It is the least-visited screen in the feature and it still needed
converting, because "practise" is a British spelling and the word is right there
in the sentence.
This is where the JSX got interesting. The converter works on whole words in
whole strings, and JSX splits copy into children:
<p className="text-muted-foreground mb-6">
Unlock the {ASSESSMENT_CENTRE_META.label} exercises to open this one and practise for
your assessment centre.
</p>
You cannot wrap that in a function, because it is three children, not a string.
So it collapses into one template literal:
<p className="text-muted-foreground mb-6">
{sp(
`Unlock the ${ASSESSMENT_CENTRE_META.label} exercises to open this one and practise for your assessment centre.`
)}
</p>
const sp = (text: string) => respell(text, spelling); at the top of each
component, and the sentence becomes one string again. This has a pleasant side
effect: a sentence in a template literal cannot lose a space at a tag boundary,
which is a bug I have written about
losing a whole afternoon to.
It also has an unpleasant one: the copy no longer shows up in a diff as prose
with markup around it, it shows up as one long line. I took that trade on the
sentences that needed it and left the rest alone.
5. The toggle, which switches without a round trip
The dashboard holds the current spelling in state and writes the cookie as a
side effect:
const handleSpellingChange = (next: ExerciseSpelling) => {
setSpelling(next);
// A year, because this is a standing preference rather than a session one.
document.cookie = `${EXERCISE_SPELLING_COOKIE}=${next}; path=/; max-age=31536000; samesite=lax`;
};
const spelledExercises = useMemo(
() => exercises.map((e) => respellSummary(e, spelling)),
[exercises, spelling]
);
The cards respell instantly in the browser; the cookie's job is only to carry
the choice to the next server render. That is why the converter imports no
content and has no server-only dependency: it has to run in both places.
Two details from the toggle itself. It is labelled "Spelling", not "Language",
because a control offering "UK English" and "US English" with no label reads as
a translation switch, and this feature deliberately does not translate anything.
And the segments are 44px tall on phones, dropping to 36 from sm up, because
44 is the touch target size and a desktop pointer does not need it.
6. The report
The downloadable PDF has its own header string, and @react-pdf/renderer
components are not the same components as the page, so it needed its own call:
<Text style={styles.subtitle}>
{respell('Assessment Centre Exercise Report', data.spelling)}
</Text>
Which meant spelling joining the PDF's data interface. A preference like this
propagates by type error, which is the good way for it to propagate.
7. The model prompt
Written responses are reviewed with AI. Two separate problems appear the moment
the reader can pick a spelling.
The first: the feedback comes back in whatever English the model feels like, so
it has to be told. The second is subtler and worse. If a candidate reads an
exercise in American spelling, writes "organize", and the marker treats that as
a spelling error, we have penalised them for using the setting we gave them. One
line, added to the prompt:
`Write every comment, strength and improvement in ${SPELLING_NAME[spelling]} English spelling. Do not treat British or American spelling in the candidate's response as an error.`
The spelling travels with the submission for this reason alone:
const submitSchema = z.object({
// ...
/** The spelling the candidate read the exercise in; the AI feedback matches it. */
spelling: z.enum(EXERCISE_SPELLINGS).optional(),
});
.optional() with a default of en-GB on the server, so a client that predates
the field, or a replayed request, still works.
8. The loading skeleton
The toggle sits at the right of the title on wide screens and under the
description on narrow ones, which means the route's loading.tsx had to grow a
placeholder in both positions. That one is a whole post of its own, and it is
the next one I am writing.
The count
One preference, one cookie, one 27-line server helper, and edits in eight
places: the server resolver, the content converter, the page metadata, the
locked screen, the dashboard, the instructions screen, the PDF and the prompt
that reviews your writing. None of the eight was hard. The work was finding all
eight, and the only reliable way I know to do that is to make the parameter
required somewhere central and then follow the compiler.
If you want to see the thing all this plumbing serves, the free e-tray scenario
on the
assessment centre practice page
runs in full without an account, the
e-tray format guide
explains what that exercise type is, and
how to pass an assessment centre
covers the day itself.
Top comments (0)