Docs

This page isn't translated yet

Frontend locales

defineSysthemaLocales: prefix and domain routing, the advanced per-locale form, missing translations.

On this page

Frontend locales are opt-in. You define them once in systhema.config.ts with defineSysthemaLocales() from @systhemaui/core, and @systhemaui/next (and @systhemaui/payload) pick them up from there. For how frontend locales relate to content localization and Admin languages, see Multilingual sites.

Defining localesLink to this section

@systhemaui/core owns the framework-neutral locale definition. Define it once in systhema.config.ts; it has no Next.js, Payload, or next-intl dependency. Omit locales entirely to preserve the existing single-language behaviour. systhema.config.ts is the sole source of locale data — nothing is serialized into a generated file; @systhemaui/next and @systhemaui/payload both read this same in-memory config (via getSysthemaConfigSync()) at build/request time instead of consuming a copy.

systhema.config.ts
import { defineSysthemaLocales, type SysthemaConfig } from '@systhemaui/core'

export const locales = defineSysthemaLocales({
  supported: ['en', 'fr'],
  default: 'en',
  fallback: true,
})

const config: SysthemaConfig = { locales }
export default config

defineSysthemaLocales() validates and canonicalizes BCP 47 codes, rejects duplicates and a default/RTL locale outside supported, and freezes the returned serializable contract. Multi-locale routing defaults to localePrefix: 'always' (/en/page, /fr/page) with locale detection on. Set routing: { localePrefix: 'as-needed' } when the default locale should keep an unprefixed URL (/page, /fr/page). Cookie-only never routing is intentionally outside the statically rendered CMS contract because locales would not have unique public URLs. rtl is an optional subset of supported; use getSysthemaLocaleDirection(locales, locale) when setting <html dir>.

fallback (default true) is the content-fallback toggle. In a Payload project it is forwarded as config.localization.fallback — whether an unpopulated localized field renders the default locale's value. A raw config.localization.fallback that disagrees makes withSysthema() throw, so set it here, not there. Do not confuse it with messages.fallback (frontend UI catalogs, see Messages and catalogs) or translations.fallback (the Admin interface language, see Admin translations) — three separate fallback knobs, three different scopes.

An exact-one-locale contract is normalized to unprefixed as-needed routing with locale detection disabled. It still establishes deterministic document language, messages, and formatting without pretending the site has a multilingual URL space.

Prefix routingLink to this section

The multi-locale default is an explicit always prefix, so a page such as slug is public at /en/slug and /hu/slug. To keep the default language unprefixed, opt into as-needed:

locales: defineSysthemaLocales({
  supported: ['en', 'hu'],
  default: 'en',
  routing: { localePrefix: 'as-needed' },
})
// /slug (English), /hu/slug (Hungarian)

Domain routingLink to this section

routing.domains optionally maps each supported locale to a public host. Host-only values normalize to HTTPS; explicit HTTP origins are accepted for loopback development. The map must cover every supported locale and use one protocol, and cannot contain credentials, a path, query, or fragment. A locale alone on its host is served unprefixed; two or more locales that share a host are path-prefixed (isSysthemaLocalePrefixedOnHost reports which), so you can freely mix dedicated domains and shared subfolders — e.g. { en: 'example.com', hu: 'example.hu', fr: 'example.eu', nl: 'example.eu' } gives example.com, example.hu, and example.eu/fr + example.eu/nl. See Public URL recipes.

const locales = defineSysthemaLocales({
  supported: ['en', 'hu'],
  default: 'en',
  routing: { domains: { en: 'example.com', hu: 'pelda.hu' } },
})

For a mapped locale, getSysthemaLocaleOrigin(locales, locale) returns its normalized origin and getSysthemaPublicHref(locales, locale, pathname) returns the absolute canonical URL. getSysthemaLocalizedPathname() is the public, path-only presentation helper; under domains it returns the unprefixed pathname for a locale alone on its host, and a locale-prefixed pathname when the locale shares its host (see isSysthemaLocalePrefixedOnHost). It is not an on-demand revalidation key. Use getSysthemaInternalLocalePathname() for internal routing and revalidation: it always produces /<locale><pathname> for an enabled locale contract, including exact-one-locale, as-needed, always, and domain routing. This keeps locale caches distinct even when their public paths are unprefixed. getSysthemaAlternates() uses absolute public URLs when origins are configured.

Advanced per-locale formLink to this section

defineSysthemaLocales() also accepts an advanced per-locale form — mutually exclusive with supported/rtl/routing.domains — for defining domain, rtl, missing, and subfolder per locale in one place instead of three separate top-level lists. Key order of locales defines the supported locale order:

const locales = defineSysthemaLocales({
  default: 'en',
  locales: {
    en: { domain: 'https://example.com' },
    hu: { domain: 'https://pelda.hu', missing: 'fallback' }, // serve EN content at the HU URL
    fr: { domain: 'https://example.eu', missing: 'redirect' }, // 307 to the EN equivalent URL
    nl: { domain: 'https://example.eu' }, // no override — inherits the contract's default (see below)
    ar: { domain: 'https://example.ae', rtl: true },
  },
  fallback: true,
  routing: { localePrefix: 'as-needed', localeDetection: true },
})

A domain set on some but not all locales still throws the existing all-or-nothing error — set it on every locale or none. rtl/missing/subfolder combine freely with either domain routing or plain path prefixing.

missing controls what happens when a page or post has no translation for a locale's URL. The default for every non-default locale is 'fallback': the requested locale's URL serves the default locale's content via Payload's display fallback — the requested locale's UI strings and <html dir> stay as requested, and only the untranslated fields borrow the default locale's value; already-translated fields still show their translation. Set missing: 'redirect' on a locale to 307-redirect that locale's URL to the default locale's equivalent URL instead. Set the contract-level fallback: false to flip the implicit default to 'notFound' everywhere (today's strict 404) — one coherent strictness switch. An explicit per-locale missing: 'notFound' still overrides that switch for one locale even under fallback: true (any explicit value, including 'notFound', always wins over the implicit default). missing cannot be set on the default locale — it throws. Draft mode and Payload Preview/Live Preview always see a real 404 on a genuine miss; they never fall back or redirect to different content. A locale whose behavior resolves to 'fallback' also prerenders its untranslated pages at the default locale's slug under its own prefix/domain (via display-fallback static-param fan-out); 'redirect'/'notFound' locales keep the strict per-locale fan-out.

Read the effective behavior for a locale with getSysthemaLocaleMissingBehavior(locales, locale): 'notFound' | 'fallback' | 'redirect' — it returns 'notFound' for the default locale or an unrecognized locale, the explicit per-locale override when one is set, and otherwise 'fallback' (or 'notFound' when the contract's fallback is false).

subfolder overrides the public URL path segment for a prefix-routed locale — it defaults to the locale code, so ar: { subfolder: 'arabic' } serves that locale at /arabic/... instead of /ar/.... It is valid for a pure path-prefixed locale and for a locale sharing a host with others in domain mode (see isSysthemaLocalePrefixedOnHost above); it throws on a dedicated-domain locale, which always serves unprefixed. A subfolder must be a single non-empty URL-safe segment, unique among the configured locales, and cannot equal another locale's own code (ambiguous with that locale). Internal routing — cache keys, the catch-all's internal segments, revalidation, static params — always uses the locale code; subfolder changes only what a browser sees. getSysthemaLocalizedPathname(), getSysthemaAlternates(), and getSysthemaPublicHref() all resolve the subfolder automatically; getSysthemaInternalLocalePathname() is unaffected. Once a locale has a subfolder, its bare /<code>/... path no longer matches — the subfolder replaces, not aliases, the code in public URLs.

The locale contract is intentionally independent from Payload Admin translations. Configuring frontend locales never selects Admin languages, and vice versa. @systhemaui/next ships reviewed frontend UI catalogs for en, hu, fr, nl, cs, sk, and ar. Register complete project catalogs for other locale codes with messages.catalogs, or deliberately opt into English UI fallback with messages.fallback: 'en'. Project namespaces are preserved alongside the Systhema namespace for next-intl. See Messages and catalogs for catalog helpers and consumer component integration.

The Next.js integrationLink to this section

@systhemaui/next includes and owns next-intl; consumers neither install nor import it directly, and no request-config module (getRequestConfig/i18n/request.ts) exists or is needed. Systhema's own components read frontend copy through useSysthemaFrontendTranslator (from @systhemaui/next/messages/client, backed by SysthemaProvider), never next-intl's client hooks — so no NextIntlClientProvider is rendered, and next-intl is used only for its middleware (routing) and navigation helpers, neither of which needs a request-config module. A consumer using next-intl directly for their own namespaces sets up their own provider in their own subtree. Locale APIs are intentionally excluded from the package root: use @systhemaui/next/locale, /locale/proxy, /messages, /messages/client, and /gateway so ordinary component imports cannot pull locale code. Without a locale value, next.config.ts and the route tree stay unchanged, and the application does not import next-intl.

Internally, the package is organised around two seams: @systhemaui/next/config for build-time contributions and @systhemaui/next/gateway for edge-time contributions. Locales contribute a config enhancer (injecting the routing contract into the edge bundle) and an edge handler (locale negotiation and redirects) to those seams. A future Systhema feature extends the same two seams rather than asking a consumer to add another file.

Declare locales only in the canonical systhema.config.ts. Turning locales on or off touches no shared file — src/proxy.ts and the catch-all page.tsx are byte-identical whether locales are configured or not. Instead:

  • next.config.ts is wrapped with withSysthema(nextConfig) (@systhemaui/next/config) — a general-purpose wrapper that runs every enabled Systhema feature's enhancer against the resolved systhema.config.ts; today that's locales, the Server Action origin allow-list and the legacy-polyfill drop, and future integrations hook in the same way with no further next.config.ts change. Its optional second argument holds the settings that are not read from systhema.config.ts (currently dropLegacyPolyfills); every one has a working default, so the one-argument call stays correct. It is baseline infrastructure: fresh scaffolds (the Next.js and Payload templates) ship it by default, and it reads systhema.config.ts at build time and, when locales is set, injects the compact locale-routing contract as a build-time env constant so the edge middleware can read it without touching the filesystem. It also appends the host of NEXT_PUBLIC_SERVER_URL to experimental.serverActions.allowedOrigins: Next rejects a Server Action whose origin host differs from x-forwarded-host, and behind the Systhema Gateway's Cloudflare Tunnel that header carries the tunnel hostname, which breaks the Payload Admin's block forms (stock fields, no swatches, a visible _tier select). Consumer entries are kept and a listed host is not repeated. With nothing configured and the variable unset, it returns nextConfig unchanged — a true no-op. Enabling locales adds the wrapper only when a project doesn't already have it; disabling locales leaves it in place.
  • src/proxy.ts still just calls systhemaGateway({ customAdminURL }) from @systhemaui/next/gateway (Payload starters import it from @systhemaui/payload/gateway, which re-exports the same function). The gateway reads that injected env constant itself and enables locale routing automatically when present — you never pass locales/localeMiddleware in by hand. See Gateway and status codes.
  • SysthemaPage (from @systhemaui/payload/next) resolves the active locale from the incoming route segments internally, using the same config, so the Payload catch-all page.tsx needs no locale-aware wrapper either.

The only file that changes shape is the owned site shell, which converts from app/(site)/template.tsx to app/(site)/shell.tsx in Payload starters (see Generated files and migration) — still outside (systhema), written once and then owned by the project: customize fonts, branding, and header/footer props there freely; sync never overwrites it. Everything inside (systhema) stays Systhema-managed and freely regenerable.

systhema.config.ts
import { defineSysthemaLocales, type SysthemaConfig } from '@systhemaui/core'

const config: SysthemaConfig = {
  locales: defineSysthemaLocales({ supported: ['en', 'hu'], default: 'en' }),
}

export default config

Missing translationsLink to this section

The advanced defineSysthemaLocales({ locales: {...} }) form's per-locale missing setting controls what a locale's URL serves when a page or post has no translation. The default ('fallback') serves the default locale's content at that URL via Payload's display fallback, keeping the requested locale's UI strings and <html dir>; 'redirect' 307s to the default locale's equivalent URL instead; and the contract-level fallback: false flips the default to 'notFound' (today's strict 404) everywhere. Draft mode and Payload Preview/Live Preview always show the real 404 on a genuine miss. See Advanced per-locale form for the full semantics and helper, and Content localization for what an editor experiences. hreflang/alternate URLs are generated independently of missing and stay pure URL math — a 'redirect'-behavior locale's alternate can point at a URL that itself redirects, while the default 'fallback' behavior keeps every alternate a real 200 page.