Summary: How to replicate Nuxt’s nuxtI18n.paths per-locale URL slugs in a Next.js Pages-Router app deployed via output: 'export'. Translated slugs at the page level (e.g. /anmelden for de, /connexion for fr, /login for en) can’t go through middleware — they need sibling page files, a shared component, and a central slug table.
Sources: website-next-js port of pages/account and pages/login (commits 40e2442, 622aa43); cross-checked against [[nuxt-website]] for the original nuxtI18n.paths blocks.
Last updated: 2026-06-02
When this applies
Any Next.js page whose Nuxt-2 ancestor declared nuxtI18n.paths. The Nuxt site has at least these:
pages/account/index.vue→de: /konto,en: /account,fr: /compte,es: /cuenta,it/nl/uk/ca/us: /accountpages/login/index.vue→de: /anmelden,en: /login,fr: /connexion,es: /iniciar-sesion,it: /accedi,nl: /inloggen,uk/ca/us: /loginpages/magazin/*↔pages/magazine/*— already ported, same shapepages/password-reset/index.vue— locale-translated slug, ported as/auth/reset-passwordfor now and deferred until the catalogue lands
If you find a Vue page with nuxtI18n.paths declared, the React port has to honour those slugs. The visible URL is part of the site’s SEO and bookmark surface; collapsing every locale onto the English slug is a regression.
Why middleware won’t do it
In Nuxt 2 the nuxt-i18n module owned URL translation at the router level — a single page file declared its per-locale slugs and the router served them up. In Next.js with output: 'export', that doesn’t work for two reasons:
- Native i18n config is incompatible with
output: 'export'.next.config.ts’si18nblock, which would have given us locale-aware routing, can’t be combined with static export. - No edge middleware runs at request time. The CF Pages deploy is pure static HTML; an
_middleware.tsPages Function does rewrite hostnames to locale folders but can’t translate slugs because the prerendered files don’t exist for the translated paths yet.
So the slug translation has to materialise at build time as actual files in out/. That means one Next.js page file per (slug × locales-with-that-slug) pair.
The pattern
Three pieces:
1. Central slug table
lib/i18n/sectionSlugs.ts owns every page’s per-locale slug map. Adding a new translated page is one entry here plus the sibling page files.
export const SECTION_SLUGS: Record<Section, Partial<Record<Locale, string>>> = {
account: {
de: 'konto',
en: 'account', fr: 'compte', es: 'cuenta',
it: 'account', nl: 'account', uk: 'account', ca: 'account', us: 'account',
},
login: {
de: 'anmelden',
en: 'login', fr: 'connexion', es: 'iniciar-sesion',
it: 'accedi', nl: 'inloggen',
uk: 'login', ca: 'login', us: 'login',
},
// …magazin, wissen, podcast already in here
}
// Helpers used by callers:
export function sectionIndexHref(section: Section, locale: Locale): string | null
export function sectionLocales(section: Section): Locale[]sectionIndexHref is what auth redirects, link generators, and the CheckoutOverlay use to build locale-correct hrefs. Never hardcode /account or /login in component code — go through this helper.
2. Shared page component
The page body lives in components/<area>/<Page>.tsx, not in pages/. Both pages/[locale]/account/index.tsx and pages/[locale]/konto/index.tsx import the same <AccountPage /> component.
The shared component reads the current locale (via useLocale()) and uses sectionIndexHref for any in-page navigation:
// In AccountPage.tsx — auth-gate redirect
const loginHref = sectionIndexHref('login', locale) ?? '/login'
const back = encodeURIComponent(sectionIndexHref('account', locale) ?? '/account')
void router.replace(`${loginHref}?redirect=${back}`)This is what guarantees a German visitor on /konto who is logged out gets bounced to /anmelden, not /login.
3. Per-slug page files (thin wrappers)
Each unique URL slug gets a tiny page file under pages/[locale]/<slug>/index.tsx. The file does one job: declare which locales render at this slug.
// pages/[locale]/konto/index.tsx — German only.
export default function Page() { return <AccountPage /> }
export const getStaticPaths: GetStaticPaths = async () => ({
paths: [{ params: { locale: 'de' } }],
fallback: false,
})
export const getStaticProps: GetStaticProps = async ({ params }) => ({
props: { locale: params!.locale as Locale },
})The “shared slug” wrapper (e.g. pages/[locale]/account/index.tsx for the locales that don’t translate) filters SECTION_SLUGS.account for entries whose value === 'account':
const LOCALES_FOR_THIS_SLUG = (Object.entries(SECTION_SLUGS.account) as [Locale, string][])
.filter(([, slug]) => slug === 'account')
.map(([locale]) => locale)That way adding a new locale to the slug table doesn’t require touching the wrapper file.
Don’t forget the catch-all exclusion
The Storyblok catch-all (pages/[locale]/[...slug].tsx) enumerates every Storyblok story slug across all locales as a static path. If editorial ever creates a Storyblok story with slug account or konto, the build crashes with Conflicting paths returned from getStaticPaths. We hit this exact failure on CF Pages when the /success page was added without an exclusion.
Defensive fix: add every page-owned slug to the exclusion list in lib/storyblok/fetchPages.ts:
if (
page.slug === 'account' || page.slug === 'konto' ||
page.slug === 'compte' || page.slug === 'cuenta' ||
page.slug === 'login' || page.slug === 'anmelden' ||
page.slug === 'connexion' || page.slug === 'iniciar-sesion' ||
page.slug === 'accedi' || page.slug === 'inloggen' ||
page.slug.startsWith('auth/')
) {
return false
}Add the slug to the list whenever you add a new translated page file, even if Storyblok doesn’t have a story at that slug today.
Cost
Per translated page family, you write:
- 1 entry in
SECTION_SLUGS - 1 shared
components/<area>/<Page>.tsx(the body) - N page files under
pages/[locale]/<slug>/index.tsxwhere N = number of unique slugs across locales (typically 3–6 for the major locales: de, fr, es, it, nl, en-family) - N entries in
fetchPages.tsexclusion list
For /account: 1 shared component + 4 page files (account, konto, compte, cuenta). For /login: 1 shared component + 6 page files (login, anmelden, connexion, iniciar-sesion, accedi, inloggen).
That’s “tedious but mechanical” — the right scope for an Agent to handle in one pass. Don’t try to be cleverer (dynamic route segments, runtime redirects, etc.); the boilerplate per file is small enough that the flatter structure is clearer.
Caveats
router.asPathcarries the locale prefix. Because Next.js doesn’t know about the CF Pages Function rewrite,router.asPathis/de/konto, not/konto. When client-side navigating (e.g.?buy=on /preise), derive the URL-bar path fromwindow.location.pathnameand pass it as the second arg torouter.push. See multi-domain-hostname-rewriting for the analogous concern in the Nuxt repo.useLocale()reads fromrouter.query.localein the React port, notrouter.locale(which is always undefined underoutput: 'export'). All locale-driven logic must go through the hook.- Deferred for now:
/auth/reset-passwordkeeps a single slug across all locales because the source Vue page (pages/auth/reset-password.vue) is the 7sleep-flavoured form, not the 7mind one. The 7mind equivalent (pages/password-reset/index.vue,nuxtI18n.paths: { de: '/passwort-zuruecksetzen', fr: '/reinitialisation-du-mot-de-passe', … }) hasn’t been ported yet.
See also
- nuxt-website — the source
nuxtI18n.pathsdeclarations on the Vue side. - multi-domain-hostname-rewriting — analogous “Nuxt does this for us, but in static export we have to do it ourselves” pattern, applied to the backend hostname rewrite that lets
7mind.de,6mind.de, and8mind.deshare a build.