next-intl extraction, locale routing, gettext catalogs, and translation rules.
The SaaS app uses next-intl with extracted messages and gettext .po catalogs.
English is the source language. UI code writes English messages through extraction APIs and translators fill non-English catalogs.
Use only:
useExtracted in synchronous components.getExtracted in async server functions.Do not use useTranslations or getTranslations in this codebase.
Translations live under apps/saas/translations/.
Extraction is enabled in apps/saas/next.config.ts via experimental.extract: true with messages.sourceLocale: "en". Committed .po location comments use path-only references (no line numbers), matching next-intl's default PO codec.
Empty msgstr values render as blank copy at runtime. Keep Dutch translations complete and run catalog tests after extraction.
SaaS uses localePrefix: "never": locale never appears in the URL. Preference is stored in the profile-locale cookie; the proxy rewrites onto the internal app/[locale]/... segment so next/root-params can resolve the locale. Showcase uses the same prefix mode. Locale codes (en, nl) are reserved and cannot be organization slugs.
Unknown or invalid locale values resolve to the default English locale instead of returning 404. Locale switchers should use native names such as English and Nederlands.
CentraKit follows the next-intl Partial Prefetching e2e sample and the next/root-params blog guidance for Cache Components — not the classic setup docs that still recommend setRequestLocale for static rendering.
There is deliberately no pass-through app/layout.tsx. The document root is app/[locale]/layout.tsx so locale stays a root param. The [locale] layout enumerates every locale with generateStaticParams, resolves lang via await getLocale(), and mounts NextIntlClientProvider. It does not call setRequestLocale or validate params.locale; locale resolution belongs in getRequestConfig.
getRequestConfig in apps/saas/lib/i18n/request.ts mirrors the sample shape (import * as rootParams, optional locale override, then rootParams.locale()). Outside a route context (Server Actions / Route Handlers), next/root-params is unavailable; catch the error, call unstable_rethrow(error) first so Next.js prerender and control-flow sentinels are preserved, then fall back to next-intl's requestLocale (proxy-supplied header). Do not swallow hanging fallback-param signals with a broad catch — that converts them into header reads and blocks App Shells / Partial Prefetching. Invalid locale values still resolve to defaultLocale rather than notFound().
Do not register orgSlug in next/root-params. Tenant slug stays URL data resolved inside Suspense islands.
experimental.globalNotFound is enabled in shared Next config. apps/saas/app/global-not-found.tsx handles unmatched URLs that never enter the [locale] tree (for example paths the i18n proxy does not rewrite). It returns a full document via AppDocument, supplies the default English catalog to NextIntlClientProvider, and reuses the same NotFoundContent as app/[locale]/not-found.tsx.
In-app misses keep AppShell. Each shell segment ([orgSlug], admin, organizations, profile, feedback) has its own not-found.tsx and a [...rest] catch-all so unmatched URLs stay inside the sidebar. app/[locale]/not-found.tsx remains the fallback for requests that never enter a shell segment and for notFound() thrown from a shell layout itself (unknown org slug).
Public SEO sitemaps live on marketing, docs, and showcase — not on SaaS. SaaS is an authenticated product surface with localePrefix: "never" (locale in the profile-locale cookie, one URL per route), so a GrahamQuan-style multi-locale sitemap with hreflang alternates does not apply.
Reuse message IDs when meaning stays the same. Create a new ID when meaning changes so translators get a clear signal.
Every t({ id, message, description }) call must include a concrete description. That text becomes the #. comment in the .po catalog and is what AI/human translators use for context.
Good descriptions name:
Examples:
Clear on an invoice date field → "Button that clears the picked invoice date; not a filter reset"tag in the invoice editor → "Inserts a merge token placeholder; not the Tags feature"unit on a line item → "Unit of measure suffix, for example hour or piece"Concept on a draft invoice → "Draft watermark standing in for the invoice number"Avoid one-word descriptions such as label or col.
Prefer natural SaaS Dutch over literal calques. Keep these glossary terms stable:
| English | Dutch |
|---|---|
| Resources (nav hub) | Middelen |
| Access | Toegang |
| Roles & Permissions | Rollen & Rechten |
| Customers | Klanten |
| Contacts | Contactpersonen |
| Tags | Tags |
| Templates | Template(s) |
| Knowledge | Kennis |
| Customer assets | Assets |
| Workflow | Workflow |
| Search / Clear search | Zoeken / Zoeken wissen |
| Notes (free-form) | Notities |
| Location (address) | Locatie |
| Size (file) | Grootte |
After extraction, fill every blank msgstr in nl.po. Empty Dutch strings render as blank UI. Run tests/integration/saas/i18n-catalog.test.ts before handoff.