Summary: How to load the full Nuxt locale message catalogue (the lang/7mind/*.js files, ~450 KB across 9 locales) at build time in a Next.js Pages-Router app with output: 'export', keep it out of the client bundle, and distribute a small per-page slice down to deeply-nested components via a React context.
Sources: website-next-js port of the locale catalogue + the in-checkout funnel (commits aa810ce, ce35279, a830d42, e82cc6f). Pairs with locale-aware-slugs-static-export.
Last updated: 2026-06-03
The problem
The Nuxt site served all per-locale copy from lang/7mind/*.js. The German catalogue alone is ~190 KB; all nine together are ~450 KB of object literals. In Nuxt, $t('couponPage.hero.heading') resolved these at runtime on the server. In a Next.js static export there is no server at request time, so the naive port (import the catalogue into a component and call t()) would bundle the entire 450 KB into the client JS for every page that touches a string. That is a non-starter.
The whole pattern below exists to load the catalogue at build time, serialize only the tiny slice a page needs into its prerendered HTML, and guarantee the rest never reaches a browser.
The pattern
1. Split pure helpers from the catalogue loader
Two modules, and the split is the whole point:
lib/i18n/translate.ts- catalogue-free. ExportscreateT(messages), theMessages/TranslateFntypes, dot-path resolution (t('a.b.0.c')), and{var}interpolation. A client component may import this freely.lib/i18n/catalogue.ts- build-time only. Statically imports all 9locales/*.js, exportsgetCatalogue(locale)andpick(catalogue, ...keys). Importing anything from here drags in all nine catalogues.
The rule that makes the boundary structural rather than tree-shaking-dependent: client components import createT from translate.ts, never from catalogue.ts. catalogue.ts is only ever imported by a page file’s getStaticProps.
2. Pick the slice in getStaticProps, pass via props
export const getStaticProps = async ({ params }) => {
const locale = params.locale
return { props: { locale, messages: pick(getCatalogue(locale), 'couponPage', 'checkout') } }
}Next.js strips getStaticProps and everything it exclusively imports out of the client bundle. So getCatalogue/pick (and the 450 KB they pull) vanish from the browser; only the picked slice is serialized into that page’s __NEXT_DATA__. The component renders with createT(messages).
3. Shallow-merge over a German fallback; synthetic strings get their own namespace
Each page keeps a FALLBACK_DE with the German values for the slices it renders, and merges the per-locale slice over it:
const t = createT({ ...FALLBACK_DE, ...(messages ?? {}) })Two non-obvious consequences:
- Coverage is uneven, and shallow-merge handles it.
couponPageexists in every locale exceptnl;giftPage/claimGiftPageexist only indeandfr(gift is a DE/FR product). For a locale missing the slice,pick()returns{}, so the spread leaves the German fallback intact. This mirrors how Vue i18n’sfallbackLocalebehaved on the live site. Caveat: the merge is shallow, so it only rescues absent top-level keys. A locale that carries a slice but with a partial nested object would lose the German sub-keys. In practice the major locales carry complete slices, andnlomits whole top-level keys rather than half-filling them, so shallow is safe here. If that stops being true, deep-merge. - Invented strings must live under a separate top-level namespace. The React port renders a few states the Vue catalogue has no key for (a bespoke coupon result view, gift button labels). If you put them under the same top-level key as a real slice (e.g.
couponPage.result), the per-locale slice merge replaces the wholecouponPageobject and silently drops them. Put them under a sibling key thatpick()never returns (couponResult,giftLocal) so the merge cannot touch them.
4. Deep component trees get a context, not prop-drilling
The in-checkout funnel (CheckoutAuth + CheckoutOverlay) renders four levels below the page and appears in several hosts (the Storyblok catch-all landing pages, /coupon, /claim-gift). Prop-drilling the checkout slice down would be miserable. Instead lib/i18n/checkoutMessages.tsx exposes:
CheckoutMessagesProvider({ messages })- readsmessages.checkout, shallow-merges it over a German default, and binds at().useCheckoutT()- the funnel components call this.
Two properties keep it safe:
- The context default is the verbatim German
checkoutcopy. A funnel rendered with no provider above it shows exactly the German it always did. Zero regression. (Use this deliberately: it let us ship the funnel localization to/couponand/claim-giftfirst and thread the catch-all provider in a later commit, with the un-threaded landing pages still rendering German in the meantime.) checkoutMessages.tsximports onlytranslate.ts. It can ship to the client without dragging the catalogue in. The slice still comes from a pagegetStaticProps.
The catch-all trap (read this before touching StoryblokRoute)
The shared Storyblok renderer components/storyblok/StoryblokRoute.tsx exports both the client component and a getStoryblokRouteProps() helper used by the page files’ getStaticProps. The getStaticProps-stripping only applies to the actual getStaticProps export in a page file, not to a helper function living in a shared client module.
So you must NOT import catalogue.ts into StoryblokRoute.tsx (or any client module). Doing so leaks all nine catalogues into every landing page’s bundle. Instead, do the pick('checkout') in each page file’s getStaticProps (pages/[locale]/index.tsx and pages/[locale]/[...slug].tsx), where stripping applies, and pass the result down as a checkoutMessages prop that StoryblokRoute forwards into CheckoutMessagesProvider.
How to verify nothing leaked
After yarn build, grep the client chunks for a catalogue string that no page picks and that appears in no component fallback. These two are reliable markers:
grep -rl "Krankenkassen übernehmen" out/_next/static/ # expect: nothing
grep -rl "Bewusster und entspannter" out/_next/static/ # expect: nothingBecause catalogue.ts imports all nine locales as one unit, the German catalogue’s absence proves the whole thing is absent. Do this check on the catch-all pages specifically (out/<locale>/index.html and a landing page), since they are the biggest surface and the easiest to leak from.
Note: the small German fallback objects baked into the page components and into checkoutMessages.tsx (a few KB) do ship to the client intentionally. They are not the catalogue. Don’t mistake a hit on "Du bist bereits bei 7Mind angemeldet" (the checkout German default) for a leak.
What’s wired vs deferred (as of 2026-06-03)
- Wired:
/account,/login(+ all locale slugs),/coupon,/claim-gift,/gift, and the in-checkout funnel state copy (back button, confirm-login, signup-success, active-subscription, redeem-failure, form headings) across/coupon,/claim-gift, and the Storyblok catch-all. - Deferred: the in-checkout LoginForm/SignupForm fields (still German; needs the
login/signupslices passed into the forms); a few React-port-only checkout states (loading spinner, health-declaration, date-aware active-subscription) sitting under a synthetic_localnamespace until the catalogue gains keys for them; the coupon editorial label-rewriting; nav/footer migration off the hand-maintainedlib/i18n/{messages,footer}.ts.
See also
- locale-aware-slugs-static-export - the companion pattern for translating URL slugs at build time.
- nuxt-website - the source
lang/7mind/*.jscatalogues and the original$t()usage.