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.
Strategy A — cookie-only (default for product apps)
- All locales are served from the same URL (
/dashboard,/settings). - The
voltro:langcookie +Accept-Languagedetermine 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 mirrorEach 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.
Cookie convention
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-Languageon the client. It's server-only —navigator.languagescan 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.