URL strategies

Cookie-only vs URL-prefix routing (Strategy A / B), the URL-prefix integration sketch, resolveLocale on the /server subpath, the voltro:lang / voltro:theme cookie convention.

There are two ways an i18n app can encode the active locale. Pick one, based on what the app's URLs are for.

  • All locales are served from the same URL (/dashboard, /settings).
  • The voltro:lang cookie + Accept-Language determine which catalog renders.
  • The ProfileMenu's language switch — or the standalone <LocaleSwitcher> from @voltro/ui-shadcn — writes the cookie and reloads.
  • Use when URLs are functional (/dashboard/projects/42/deployments) and don't need to encode the language. This is most apps.

This is the zero-config default — the framework's auto-wired <I18nProvider> is cookie-driven, so Strategy A needs nothing beyond setting locales in app.config.ts.

Strategy B — URL-prefix (default for docs / marketing)

  • The default locale lives at the bare path (/docs/foo).
  • Other locales prepend /<code> (/de/docs/foo).
  • Each translated page has its own crawlable URL → better SEO, shareable language-specific links.
  • Use when URLs are the product — search engines index them, links get shared with specific language intent.
  • Requires extra wiring in the layout (below).

The two strategies can coexist across sibling apps: voltro-cloud's dashboard uses cookie-only; its docs app (the one you're reading) uses URL-prefix.

Render mode constrains the choice

Strategy A isn't always available — it depends on how the page renders. Cookie i18n only shows the chosen language two ways: (1) the page hydrates on the client (interactive: 'full', the default) and re-renders from the cookie, or (2) the page is ssr / isr and the server resolves the cookie per request.

A page that is renderMode: 'static' and interactive: 'none' / 'islands' runs neither — its HTML is pre-rendered once in the default locale and never re-renders. Cookie i18n there is a dead switcher: it writes the cookie, reloads, and shows the same default-language HTML. Zero-JS static sites and islands pages therefore must use Strategy B, whose [locale] mirrors make voltro build pre-render one HTML file per locale — genuinely bilingual with no client JS.

Page i18n strategy
hydrated (interactive: 'full') or ssr / isr Strategy A (cookie) works
static + interactive: 'none' / 'islands' Strategy B (URL-prefix) required

The init-templates follow exactly this split: the hydrated/SSR shells (app, blank, spa, ssr, ssr-api, admin, dashboard) ship cookie i18n; the static/islands ones (landing, docs, static-blog, changelog, contact) ship URL-prefix i18n.

URL-prefix integration (Strategy B)

The framework's auto-wired <I18nProvider> is cookie-driven. For URL-prefix routing you need an inner provider that's URL-driven, so client-side navigation between /docs/x and /de/docs/x swaps the catalog without a full reload.

// src/lib/locale.ts
import { useLocation } from '@voltro/web'

export const SUPPORTED = ['en', 'de'] as const
export const DEFAULT = 'en'
type Locale = (typeof SUPPORTED)[number]

const isSupported = (v: string): v is Locale =>
  (SUPPORTED as ReadonlyArray<string>).includes(v)

export const localeFromPathname = (p: string): Locale => {
  const m = /^\/([a-z]{2})(?:\/|$)/.exec(p)
  return m && isSupported(m[1]!) ? m[1] as Locale : DEFAULT
}

export const useUrlLocale = (): Locale => localeFromPathname(useLocation())

export const withLocalePrefix = (path: string, locale: string): string => {
  if (locale === DEFAULT) return path
  const p = path.startsWith('/') ? path : `/${path}`
  return p === '/' ? `/${locale}` : `/${locale}${p}`
}

export const stripLocalePrefix = (p: string): string => {
  const m = /^\/([a-z]{2})(\/.*|$)/.exec(p)
  return m && isSupported(m[1]!) ? (m[2] || '/') : p
}
// src/pages/layout.tsx
import { I18nProvider } from '@voltro/i18n'
import en from '../locales/en'
import de from '../locales/de'
import { useUrlLocale, DEFAULT } from '../lib/locale'

const CATALOGS = { en, de } as const

const Body = ({ children }) => {
  // EVERY useT() / <T> in here reads from the inner provider
  // (URL-driven). useLocation in useUrlLocale triggers re-render
  // on client-side nav → inner provider re-renders with the new
  // catalog → all useT lookups update.
  return <>{children}</>
}

export default function Layout({ children }) {
  const locale = useUrlLocale()
  return (
    <I18nProvider locale={locale} messages={CATALOGS[locale]} defaultLocale={DEFAULT}>
      <Body>{children}</Body>
    </I18nProvider>
  )
}

Pair this with locale-prefixed page files: src/pages/[locale]/index.tsx, src/pages/[locale]/docs/[...slug].tsx, etc. Each re-exports the default-locale query's component, which reads useUrlLocale() to decide which catalog data to query.

Reference implementations: voltro-dev/apps/voltro-dev/docs/ (URL-prefix on dynamic + static content) and voltro-dev/apps/voltro-dev/landing/ (URL-prefix on a pure static marketing site — 10 default-locale pages, 10 locale-prefixed mirrors, one combined voltro build run).

SSG emission per locale — what voltro build does for you

When locales is set in app.config.ts AND the page tree includes [locale]/… mirror files, voltro build automatically emits per-locale static HTML at the URL-prefixed paths:

dist/index.html                        ← default locale (en)
dist/de/index.html                     ← de mirror
dist/features/foo/index.html           ← default locale
dist/de/features/foo/index.html        ← de mirror

Each variant ships with the right <I18nProvider>-wrapped body and the right per-locale <title> / <meta description> / <link rel="canonical"> / OG tags — if the page's meta is exported as a function of ({ locale }). With a plain static meta: PageMeta object, the body is correctly localised but the head tags stay default-locale on every variant.

// src/pages/features/foo.tsx — meta as a function of locale
import { getCatalog } from '../lib/locale'
import { localeCanonicalUrl, ogTags, standardLinks } from '../lib/seo'

export const meta = ({ locale }: { readonly locale: string }) => {
  const c = getCatalog(locale)
  const title = c['seo.features.foo.title'] as string
  const description = c['seo.features.foo.description'] as string
  return {
    title,
    description,
    canonical: localeCanonicalUrl('/features/foo', locale),
    tags: ogTags({ title, description }),
    links: standardLinks('/features/foo'),
  }
}

The meta(ctx) callback runs once per (page × locale) at build time. The ctx.locale value comes from params.locale for the [locale]/… mirror routes; for the bare-path variant it's the app's defaultLocale.

Mechanism

At build time the SSG pipeline pre-loads every catalog and wraps each rendered page in a per-locale <I18nProvider>. The useT() calls inside pages, layouts, and shared components resolve against the active locale's catalog — no IntlProvider context error, no client-side flash of untranslated content. This is invariant: it fires whether you set locales for client-routing reasons (Strategy A: cookies) or full SSG (Strategy B: URL-prefix).

The [locale]/… mirror is the trigger for SSG output — without mirror files, only the default-locale URLs are emitted (the framework won't guess that /de/foo should also exist). With mirror files, their getStaticPaths enumerate which locale-prefixed paths to render, and the build produces one HTML file per (page × locale) combination.

Mirror file boilerplate

12 lines per page. Re-export the canonical page's default, renderMode, interactive, meta, plus a getStaticPaths that enumerates non-default locales:

// src/pages/[locale]/features/foo.tsx
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../lib/locale'
export { default } from '../../features/foo'
export { renderMode, interactive, meta } from '../../features/foo'

export const getStaticPaths = async (): Promise<
  Array<{ params: { locale: string } }>
> =>
  SUPPORTED_LOCALES
    .filter((l) => l !== DEFAULT_LOCALE)
    .map((locale) => ({ params: { locale } }))

Cloud-docs and voltro-dev both use this exact pattern — see them for the lib/locale.ts helpers (SUPPORTED_LOCALES, DEFAULT_LOCALE, getCatalog, useUrlLocale, withLocalePrefix, stripLocalePrefix).

Server-side resolution — the /server subpath

import { resolveLocale } from '@voltro/i18n/server'

// In any SSR-side layout, lib, or middleware:
const locale = resolveLocale({
  cookieHeader: ssr.headers.cookie,
  acceptLanguageHeader: ssr.headers['accept-language'],
  supported: ['en', 'de'],
  defaultLocale: 'en',
})

resolveLocale is what the framework's auto-wired entry uses under the hood — exposed here for consumers who need locale info before the React tree mounts: setting a lang attribute on <html>, emitting hreflang link tags, picking a locale-specific OG image. It applies the same priority order — cookie → Accept-Language → default — and the returned locale is guaranteed to be in supported.

The /server subpath is convention. Both that path and the main entry are pure JS (no Node-only APIs); the split exists so future server-only helpers stay out of browser bundles.

The framework reads + writes voltro:lang for the active language choice (lowercase IETF tag: en, de, fr-CA). @voltro/ui-shadcn's ProfileMenu and the framework's auto-wired resolver agree on this name — don't pick a different one in app code.

For theme the parallel cookie is voltro:theme (values 'system' | 'light' | 'dark'). Both are managed by the kit's ProfileMenu out of the box; the helpers live in @voltro/ui-shadcn (THEME_COOKIE, LANG_COOKIE, getCookie, setCookie, deleteCookie, parsePreferenceCookies, applyTheme).

Anti-patterns

  • Don't mix strategies within one app. Pick cookie-only or URL-prefix per app; mixing them produces ambiguous canonical URLs and broken language switching.
  • Don't read Accept-Language on the client. It's server-only — navigator.languages can diverge from what the server saw and cause a hydration mismatch.
  • Don't invent your own cookie name. The kit and the resolver only agree on voltro:lang / voltro:theme.