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.vuede: /konto, en: /account, fr: /compte, es: /cuenta, it/nl/uk/ca/us: /account
  • pages/login/index.vuede: /anmelden, en: /login, fr: /connexion, es: /iniciar-sesion, it: /accedi, nl: /inloggen, uk/ca/us: /login
  • pages/magazin/*pages/magazine/* — already ported, same shape
  • pages/password-reset/index.vue — locale-translated slug, ported as /auth/reset-password for 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:

  1. Native i18n config is incompatible with output: 'export'. next.config.ts’s i18n block, which would have given us locale-aware routing, can’t be combined with static export.
  2. No edge middleware runs at request time. The CF Pages deploy is pure static HTML; an _middleware.ts Pages 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.tsx where 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.ts exclusion 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.asPath carries the locale prefix. Because Next.js doesn’t know about the CF Pages Function rewrite, router.asPath is /de/konto, not /konto. When client-side navigating (e.g. ?buy= on /preise), derive the URL-bar path from window.location.pathname and pass it as the second arg to router.push. See multi-domain-hostname-rewriting for the analogous concern in the Nuxt repo.
  • useLocale() reads from router.query.locale in the React port, not router.locale (which is always undefined under output: 'export'). All locale-driven logic must go through the hook.
  • Deferred for now: /auth/reset-password keeps 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.paths declarations 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, and 8mind.de share a build.