Offer the whole product in German, with German as the default locale and English secondary.
Outcome: German-speaking organizers and contributors use the entire product in German, including print output that looks right in German.
Two separate audiences, separately scoped languages: organizers (authenticated workspace) and contributors (anonymous share link).
Decisions
| Area |
Decision |
| Default locale |
German; English secondary |
| i18n library |
Paraglide (@inlang/paraglide-js) — compile-time, type-safe, tree-shaken |
| Locale detection |
paraglideMiddleware with baseLocale: "de", strategy: ["cookie", "preferredLanguage", "baseLocale"] |
| Organizer UI |
Cookie-backed, header dropdown beside the theme toggle (app-header.tsx:53) |
| Book language |
Per project, set in NewProjectDialog (projects.index.tsx:57), prefilled from UI language |
| Contributor form |
Follows book language; no contributor-facing switcher |
| URLs |
No locale prefixes, no hreflang — cookie only |
| Existing projects |
Backfill en; new projects default de |
| Hyphenation |
hyphen/de-1996 inside wrapRuns (text-layout.ts:80), keyed off book language |
| Legal texts |
German authored from a German generator, DeepL'd into English |
| E2E |
124 selectors move to data-testid |
The book-language field drives four things: contributor form chrome on /s/$token, the two defaults that reach the printed page, which hyphenation patterns the layout engine loads, and date formatting shown to contributors.
Locale scoping rule
Ambient locale for anything an organizer reads. Explicit locale for anything that reaches the printed page or the contributor.
Paraglide's getLocale() is ambient — one locale per request. This product needs two locale scopes live simultaneously: the organizer's UI locale and the project's book language.
This matters most in text-layout.ts. If hyphenation reads ambient locale, a German organizer previewing an English book gets German hyphenation baked into the PDF. So text-layout.ts and the layout.ts defaults must take locale as an explicit parameter, never ambient — keeping them pure and keeping layout-parity.spec.ts honest. Paraglide message functions accept an explicit locale option for exactly this case.
Constraints
- Browser preview and PDF must agree —
layout-parity.spec.ts enforces it. Hyphenation goes in the shared engine; CSS hyphens: auto is unusable because the browser would hyphenate and the PDF would not.
- Fonts are already fine — all 30 print cuts carry metrics for ä ö ü Ä Ö Ü ß and „ “. No re-subsetting needed.
- No in-app email. Clerk owns auth UI and emails — dashboard config plus its
deDE localization, no app code.
- Only two app-authored strings reach the printed page:
"A little note" (layout.ts:299) and the "Question" fallback label (text-layout.ts:55). Both must follow book language, not UI language.
Risks, highest first
-
Stale problems when hyphenation lands. Books store only references and a problems array (book.ts:30-52); text is re-laid-out from live submission data at export. Enabling hyphenation changes requiredLines, so an approved book can export differently than it was reviewed. Needs a regeneration prompt or a layout-engine version stamp. The en backfill contains the blast radius to German books only.
-
problemSchema persists rendered prose, which cannot be re-localized. book.ts:3-21 stores both a code enum and a rendered message: z.string().min(1), saved with book pages. Switch UI language and stored problems stay in the old one until regeneration. This is a latent bug today, independent of i18n.
Fix: stop storing prose. code is already a discriminated enum covering all nine cases — keep it, store the parameters alongside, render at display time. plural(), lineCount(), and the message builders in generation.ts (lines 77-108, 174-178) largely disappear rather than get translated.
Widens the change: message is in the zod validator on the book API payload, so this touches api.projects.$projectId.book.ts and existing stored rows.
-
German overflow rates in layouts authored against English text lengths. wrapRuns currently wraps on whitespace only and places an oversized word anyway (|| !line, text-layout.ts:85), failing the fit check. Hyphenation reduces this but will not eliminate it; expect layout tuning after the first real German books.
Adapted from the TanStack Router i18n guide
Adopt: Paraglide as the library; paraglideMiddleware for SSR locale detection; getLocale() to set the lang attribute; a strict Locale type with a type guard, mirroring the existing workspaceSteps / parseWorkspaceStep pattern (workspace-tabs.ts:1-9).
Skip: the entire URL-prefix half — {-$locale} optional path params, the rewrite option, localizeUrl / deLocalizeUrl, and localized prerenderRoutes. Ruled out with the no-English-SEO decision.
Spike first: Paraglide 2 handles plurals via variants and Intl.PluralRules, which is correct for German, but there is an open bug where local variables do not compile into the message function (opral/paraglide-js#374). Verify before committing to that syntax.
Scope reference
Roughly 250-350 user-facing strings, concentrated in layout-editor.tsx (~104), book-review.tsx (~36), form-builder.tsx (~23), privacy.tsx (~20), plus ~57 server error messages.
Loose ends
lang="en" is hardcoded (__root.tsx:101) — set it from getLocale()
- Three
toLocaleString() calls need explicit locales (projects.$projectId.tsx:204,332, submissions-panel.tsx:233)
- Seed demo projects are English (
scripts/seed.ts) — likely should be German now
- PDF metadata title is
"Sakekeep friend book" (pdf-renderer.ts:503)
Suggested starting point
The hyphenation-plus-book-language change is the deepest coupling and the only part that can alter already-approved book output — worth spiking before the string extraction, which is mechanical and low-risk by comparison.
This may split into two issues at planning time: string/locale plumbing, and the hyphenation/book-language work carrying the regeneration-stamp risk.
References
Offer the whole product in German, with German as the default locale and English secondary.
Outcome: German-speaking organizers and contributors use the entire product in German, including print output that looks right in German.
Two separate audiences, separately scoped languages: organizers (authenticated workspace) and contributors (anonymous share link).
Decisions
@inlang/paraglide-js) — compile-time, type-safe, tree-shakenparaglideMiddlewarewithbaseLocale: "de",strategy: ["cookie", "preferredLanguage", "baseLocale"]app-header.tsx:53)NewProjectDialog(projects.index.tsx:57), prefilled from UI languageen; new projects defaultdehyphen/de-1996insidewrapRuns(text-layout.ts:80), keyed off book languagedata-testidThe book-language field drives four things: contributor form chrome on
/s/$token, the two defaults that reach the printed page, which hyphenation patterns the layout engine loads, and date formatting shown to contributors.Locale scoping rule
Ambient locale for anything an organizer reads. Explicit locale for anything that reaches the printed page or the contributor.
Paraglide's
getLocale()is ambient — one locale per request. This product needs two locale scopes live simultaneously: the organizer's UI locale and the project's book language.This matters most in
text-layout.ts. If hyphenation reads ambient locale, a German organizer previewing an English book gets German hyphenation baked into the PDF. Sotext-layout.tsand thelayout.tsdefaults must take locale as an explicit parameter, never ambient — keeping them pure and keepinglayout-parity.spec.tshonest. Paraglide message functions accept an explicit locale option for exactly this case.Constraints
layout-parity.spec.tsenforces it. Hyphenation goes in the shared engine; CSShyphens: autois unusable because the browser would hyphenate and the PDF would not.deDElocalization, no app code."A little note"(layout.ts:299) and the"Question"fallback label (text-layout.ts:55). Both must follow book language, not UI language.Risks, highest first
Stale
problemswhen hyphenation lands. Books store only references and aproblemsarray (book.ts:30-52); text is re-laid-out from live submission data at export. Enabling hyphenation changesrequiredLines, so an approved book can export differently than it was reviewed. Needs a regeneration prompt or a layout-engine version stamp. Theenbackfill contains the blast radius to German books only.problemSchemapersists rendered prose, which cannot be re-localized.book.ts:3-21stores both acodeenum and a renderedmessage: z.string().min(1), saved with book pages. Switch UI language and stored problems stay in the old one until regeneration. This is a latent bug today, independent of i18n.Fix: stop storing prose.
codeis already a discriminated enum covering all nine cases — keep it, store the parameters alongside, render at display time.plural(),lineCount(), and the message builders ingeneration.ts(lines 77-108, 174-178) largely disappear rather than get translated.Widens the change:
messageis in the zod validator on the book API payload, so this touchesapi.projects.$projectId.book.tsand existing stored rows.German overflow rates in layouts authored against English text lengths.
wrapRunscurrently wraps on whitespace only and places an oversized word anyway (|| !line,text-layout.ts:85), failing the fit check. Hyphenation reduces this but will not eliminate it; expect layout tuning after the first real German books.Adapted from the TanStack Router i18n guide
Adopt: Paraglide as the library;
paraglideMiddlewarefor SSR locale detection;getLocale()to set thelangattribute; a strictLocaletype with a type guard, mirroring the existingworkspaceSteps/parseWorkspaceSteppattern (workspace-tabs.ts:1-9).Skip: the entire URL-prefix half —
{-$locale}optional path params, therewriteoption,localizeUrl/deLocalizeUrl, and localizedprerenderRoutes. Ruled out with the no-English-SEO decision.Spike first: Paraglide 2 handles plurals via variants and
Intl.PluralRules, which is correct for German, but there is an open bug where local variables do not compile into the message function (opral/paraglide-js#374). Verify before committing to that syntax.Scope reference
Roughly 250-350 user-facing strings, concentrated in
layout-editor.tsx(~104),book-review.tsx(~36),form-builder.tsx(~23),privacy.tsx(~20), plus ~57 server error messages.Loose ends
lang="en"is hardcoded (__root.tsx:101) — set it fromgetLocale()toLocaleString()calls need explicit locales (projects.$projectId.tsx:204,332,submissions-panel.tsx:233)scripts/seed.ts) — likely should be German now"Sakekeep friend book"(pdf-renderer.ts:503)Suggested starting point
The hyphenation-plus-book-language change is the deepest coupling and the only part that can alter already-approved book output — worth spiking before the string extraction, which is mechanical and low-risk by comparison.
This may split into two issues at planning time: string/locale plumbing, and the hyphenation/book-language work carrying the regeneration-stamp risk.
References