diff --git a/apps/web/package.json b/apps/web/package.json
index 158b6c3c5..437a29c31 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -45,6 +45,7 @@
"shadcn": "^4.14.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.18",
+ "use-intl": "^4.13.7",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/apps/web/src/components/language-switcher.tsx b/apps/web/src/components/language-switcher.tsx
new file mode 100644
index 000000000..675118b42
--- /dev/null
+++ b/apps/web/src/components/language-switcher.tsx
@@ -0,0 +1,67 @@
+import { useLanguages } from '@vitnode/core/components/languages-provider'
+import { Button } from '@vitnode/core/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@vitnode/core/components/ui/dropdown-menu'
+import { CheckIcon, LanguagesIcon } from 'lucide-react'
+import { useTranslations } from 'use-intl'
+
+import type { Locale } from '#/lib/i18n/shared'
+
+import { useLocale, useSwitchLocale } from '#/lib/i18n/client'
+
+/**
+ * VitNode's language switcher, for TanStack Router.
+ *
+ * The same control as `@vitnode/core`'s - the same dropdown, the same icons, the
+ * same `core.global.language_switcher` label - over a different navigation
+ * layer. Core's version is built on `next-intl/navigation`'s `useRouter`, which
+ * is Next.js all the way down; this one is built on the router that is actually
+ * mounted here. Sharing the markup and forking the two lines that navigate is
+ * cheaper than a navigation abstraction that has to satisfy both.
+ *
+ * What it preserves is the whole point: the route, its params, its search
+ * string and its hash. Only the locale prefix changes.
+ */
+export const LanguageSwitcher = () => {
+ const languages = useLanguages()
+ const locale = useLocale()
+ const switchLocale = useSwitchLocale()
+ const t = useTranslations('core.global')
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ {languages.map((language) => (
+ {
+ switchLocale(language.code as Locale)
+ }}
+ >
+ {language.name}
+
+ {language.code === locale && (
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts
index 3c14e3749..eb65c5a97 100644
--- a/apps/web/src/i18n.ts
+++ b/apps/web/src/i18n.ts
@@ -13,7 +13,7 @@ import type { VitNodeI18nConfig } from '@vitnode/core/lib/i18n/types'
* reword something without forking the package that owns it.
*/
export const i18n = {
- defaultLocale: 'en',
+ defaultLocale: 'en' as const,
/**
* Explicit, because the app renders on a server: without one, `use-intl`
* formats dates in whatever zone the server happens to run in and warns that
@@ -21,13 +21,18 @@ export const i18n = {
* per-visitor zone would come from.
*/
timeZone: 'UTC',
+ /**
+ * `as const` on each code, and nothing else: it keeps `"en" | "pl"` out of
+ * the widening `satisfies` would otherwise do, which is what makes `Locale`
+ * in `lib/i18n/shared.ts` a real union rather than an alias for `string`.
+ */
locales: [
{
- code: 'en',
+ code: 'en' as const,
name: 'English',
},
{
- code: 'pl',
+ code: 'pl' as const,
name: 'Polski',
},
],
diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts
deleted file mode 100644
index d80016006..000000000
--- a/apps/web/src/lib/i18n.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { queryOptions } from '@tanstack/react-query'
-import { createServerFn } from '@tanstack/react-start'
-
-import { loadShellIntl } from '#/server/messages.server'
-
-/**
- * The shell's locale and its `core.global` strings, fetched on the server.
- *
- * A server function rather than a plain loader: the messages are read from JSON
- * inside each package's `dist`, which only exists on the server, and the plugin
- * registry they are merged from must never reach the browser bundle. Start
- * strips the handler - and everything only it imports - out of the client build.
- */
-export const getShellIntl = createServerFn().handler(
- async () => await loadShellIntl(),
-)
-
-/**
- * The same request, as a query.
- *
- * Going through the QueryClient rather than returning it from the loader is what
- * makes the shell's copy of it *the* copy: the root loader warms it on the
- * server, the SSR integration dehydrates it into the HTML, and the component
- * reads it out of the hydrated cache instead of asking the server again. It is
- * also the first real exercise of the Stage 2 pipeline - router context, loader,
- * `ensureQueryData`, dehydrate, hydrate - which is worth having under something
- * the page visibly needs rather than a synthetic query.
- *
- * `staleTime: Infinity`: a locale's messages change when the app is redeployed.
- */
-export const shellIntlQueryOptions = () =>
- queryOptions({
- queryFn: async () => await getShellIntl(),
- queryKey: ['vitnode', 'shell-intl'] as const,
- staleTime: Infinity,
- })
diff --git a/apps/web/src/lib/i18n/client.ts b/apps/web/src/lib/i18n/client.ts
new file mode 100644
index 000000000..09ba752a1
--- /dev/null
+++ b/apps/web/src/lib/i18n/client.ts
@@ -0,0 +1,226 @@
+import type { QueryClient } from '@tanstack/react-query'
+import type { AnyRouter, LocationRewrite } from '@tanstack/react-router'
+
+import { useRouter, useRouterState } from '@tanstack/react-router'
+import { createIsomorphicFn } from '@tanstack/react-start'
+import { getRequestHeader } from '@tanstack/react-start/server'
+import {
+ readLocaleCookie,
+ serializeLocaleCookie,
+} from '@vitnode/core/lib/i18n/locale-cookie'
+
+import type { Locale } from './shared'
+
+import { intlQueryOptions } from './query'
+import { localeRouting } from './shared'
+
+/**
+ * A base for parsing a router href that carries no origin. Never requested, and
+ * never rendered - only `pathname`, `search` and `hash` are ever read back off
+ * it.
+ */
+const RELATIVE_BASE = 'https://vitnode.invalid'
+
+/**
+ * The remembered language, wherever this happens to be running.
+ *
+ * Only routes outside the localized URL space ever ask - `/admin`, and anything
+ * else in `DEFAULT_IGNORED_LOCALE_PATHS`. A public URL says which language it is
+ * in, and this must never get a vote there.
+ *
+ * `createIsomorphicFn` is what keeps that one question from becoming two
+ * functions that drift: the Start compiler keeps only the branch for the bundle
+ * it is building, so the browser never sees `getRequestHeader` and the server
+ * never touches `document`. Un-compiled - in tests, under plain Node - the stub
+ * falls back to the server branch, which is the right default for a test run.
+ */
+const readCookieLocale = createIsomorphicFn()
+ .server(() => {
+ try {
+ return readLocaleCookie(getRequestHeader('cookie'))
+ } catch {
+ // `getRequestHeader` throws outside a request scope, which is where a
+ // prerender pass and a test both build links from. No request means no
+ // stored preference, which is exactly what the default locale is for.
+ return undefined
+ }
+ })
+ // The locale cookie is deliberately not `HttpOnly`, so this read works: the
+ // switcher writes it in the browser and the server reads the same one back.
+ .client(() => readLocaleCookie(globalThis.document?.cookie))
+
+/**
+ * The language a URL is served in - the one authoritative answer.
+ *
+ * Everything that needs a locale comes through here: the router rewrite that
+ * writes prefixes into links, ``, the message query, the switcher.
+ * There is deliberately no second source to disagree with it.
+ *
+ * `publicPathname` is the URL in the address bar, *before* the router rewrote
+ * the prefix away. Handing it the internal path would resolve every request to
+ * the default locale.
+ *
+ * The cookie is the only source handed to the shared helper, and that is the
+ * whole contract for a route with no locale in its URL: **cookie, then the
+ * default.** The helper can also negotiate an `Accept-Language` header and
+ * deliberately is not asked to - the browser cannot read request headers, so a
+ * server that answered `pl` from one would hydrate to `en` on the client: a
+ * flash of the wrong language and a React hydration mismatch on every first
+ * visit. First-visit negotiation is a product decision that needs its own
+ * hydration-safe design, not a source quietly added here.
+ *
+ * The cast is safe by construction: the answer is either a code this app was
+ * configured with or the default, never anything from the URL.
+ */
+export const resolveLocale = (publicPathname: string): Locale =>
+ localeRouting.resolveLocale(publicPathname, {
+ // A getter, so a public path - which is every request this app serves
+ // today - never reads a cookie at all.
+ get cookieLocale() {
+ return readCookieLocale()
+ },
+ }) as Locale
+
+/**
+ * The path shown in the address bar, from a location the router parsed.
+ *
+ * Takes the one field it reads rather than a `ParsedLocation`, so it is equally
+ * callable with `router.latestLocation`, with router state, and with a
+ * `beforeLoad`'s `location` - three types that differ only in their search
+ * schema.
+ */
+export const publicPathnameOf = ({
+ publicHref,
+}: {
+ publicHref: string
+}): string => new URL(publicHref, RELATIVE_BASE).pathname
+
+/**
+ * The router's half of locale routing: one route tree, two public URL shapes.
+ *
+ * browser /pl/discover --input--> /discover route tree
+ * --output-> /pl/discover rendered href
+ *
+ * `input` is why no route file ever mentions a locale. It is also why an unknown
+ * first segment still 404s: only a prefix this app actually writes gets
+ * stripped, so `/xx/discover` reaches the route tree intact and matches nothing.
+ *
+ * `output` reads the locale from the router's own current location rather than
+ * from `window` or a module variable. That is the same value on the server (a
+ * memory history seeded with the request) as in the browser (the address bar),
+ * which is what keeps the `href` React renders during SSR identical to the one
+ * it renders after hydration.
+ */
+export const createLocaleRewrite = (
+ getRouter: () => AnyRouter | undefined,
+): LocationRewrite => ({
+ input: ({ url }) => localeRouting.deLocalizeUrl(url),
+ output: ({ url }) => {
+ const location = getRouter()?.latestLocation
+ // Before the router has parsed a location there is nothing to read a locale
+ // from - and no link has been built yet either.
+ if (!location) return url
+
+ return localeRouting.localizeUrl(
+ url,
+ resolveLocale(publicPathnameOf(location)),
+ )
+ },
+})
+
+/**
+ * The language the page is currently in, as reactive state.
+ *
+ * Subscribed to the router's location rather than read off `window`, so a
+ * language switch re-renders everything downstream of it - the provider, the
+ * message query, `` - with no reload and no second source of truth.
+ */
+export const useLocale = (): Locale =>
+ useRouterState({
+ select: (state) => resolveLocale(publicPathnameOf(state.location)),
+ })
+
+/**
+ * Puts a language's messages in the cache before anything renders in it.
+ *
+ * Failure is deliberately not fatal: the switch still happens, and the
+ * provider's own query retries it. A language that cannot be fetched should
+ * degrade to a moment of loading, not to a switcher that appears to do nothing.
+ */
+const warmMessages = async (router: AnyRouter, locale: Locale) => {
+ const { queryClient } = router.options.context as {
+ queryClient?: QueryClient
+ }
+
+ try {
+ await queryClient?.ensureQueryData(intlQueryOptions({ locale }))
+ } catch {
+ /* empty */
+ }
+}
+
+/**
+ * Switches the page's language, keeping the visitor exactly where they are.
+ *
+ * /discover -> pl -> /pl/discover
+ * /pl/discover?q=hello -> en -> /discover?q=hello
+ * /admin/users -> pl -> /admin/users (only the language changes)
+ *
+ * Why `history.push` rather than `navigate()`: the locale lives *only* in the
+ * public URL. Internally `/discover` and `/pl/discover` are the same location,
+ * and `commitLocation` compares internal hrefs to decide whether anything moved
+ * - so a `navigate()` to the same route is a no-op and the address bar never
+ * changes. Pushing the public href is what the router itself does at the end of
+ * every navigation (`this.history.push(nextHistory.publicHref)`), so this is the
+ * same client-side transition, not a document reload.
+ *
+ * `invalidate()` then re-runs the matched routes. Two reasons: the internal URL
+ * did not change, so nothing looks stale to the router even though every loader
+ * that read `context.locale` now holds the previous answer - and on an ignored
+ * route such as `/admin`, where the URL does not change at all, it is the whole
+ * of the switch.
+ *
+ * Written as a plain function over a router rather than only as a hook, so the
+ * behaviour above is testable without mounting React.
+ */
+export const switchLocaleOn = async (
+ router: AnyRouter,
+ locale: Locale,
+): Promise => {
+ if (!localeRouting.isSupportedLocale(locale)) return
+
+ // Fetched before the URL moves, not after. The location store updates the
+ // moment history does, so the provider re-renders under the new locale - and
+ // therefore the new query key - while the root loader is still resolving it.
+ // That is a suspend, and a suspend caused by a store update cannot be
+ // deferred: the page would blank for a round trip. Warmed first, the switch
+ // is a re-render with the messages already in hand.
+ await warmMessages(router, locale)
+
+ const current = new URL(router.latestLocation.publicHref, RELATIVE_BASE)
+ const next = localeRouting.localizeUrl(current, locale)
+
+ if (next.href !== current.href) {
+ router.history.push(`${next.pathname}${next.search}${next.hash}`)
+ }
+
+ await router.invalidate()
+}
+
+/** {@link switchLocaleOn}, bound to the mounted router, plus the cookie write. */
+export const useSwitchLocale = () => {
+ const router = useRouter()
+
+ return (locale: Locale) => {
+ // Remembered for the routes whose URL carries no locale - `/admin` - and for
+ // the next visit. `Secure` only over HTTPS: set on plain `http://localhost`
+ // the browser drops it without a word, and the choice never sticks.
+ if (localeRouting.isSupportedLocale(locale)) {
+ globalThis.document.cookie = serializeLocaleCookie(locale, {
+ secure: globalThis.location.protocol === 'https:',
+ })
+ }
+
+ void switchLocaleOn(router, locale)
+ }
+}
diff --git a/apps/web/src/lib/i18n/query.ts b/apps/web/src/lib/i18n/query.ts
new file mode 100644
index 000000000..83bc8d983
--- /dev/null
+++ b/apps/web/src/lib/i18n/query.ts
@@ -0,0 +1,178 @@
+import { queryOptions } from '@tanstack/react-query'
+import { createServerFn } from '@tanstack/react-start'
+
+import { loadIntlMessages } from '#/server/messages.server'
+
+import type { Locale } from './shared'
+
+import { localeRouting } from './shared'
+
+/** The strings every page needs, whatever else it renders. */
+export const GLOBAL_NAMESPACE = 'core.global'
+
+/**
+ * Namespaces in a form two callers cannot spell differently.
+ *
+ * Sorted and de-duplicated, because the list is part of the query key: without
+ * this, `["core.global", "core.discover"]` and `["core.discover", "core.global"]`
+ * are two cache entries holding the same bytes, fetched twice and invalidated
+ * separately.
+ *
+ * Normalisation only - it assumes strings, and says nothing about whether they
+ * are acceptable. That is {@link assertNamespace}'s job, and it runs on the
+ * server where the input is untrusted.
+ */
+const normalizeNamespaces = (namespaces: readonly string[]): string[] =>
+ [...new Set(namespaces)].sort((a, b) => a.localeCompare(b))
+
+/**
+ * More than any page has ever needed, and few enough that a caller asking for
+ * thousands is refused rather than served.
+ */
+export const MAX_NAMESPACES = 16
+
+/** `@vitnode/some-plugin.a.b.c` is four; nothing real goes deeper. */
+export const MAX_NAMESPACE_DEPTH = 8
+
+/** Comfortably longer than the longest plugin id plus a namespace path. */
+export const MAX_NAMESPACE_LENGTH = 128
+
+/**
+ * Segments that must never reach {@link pickMessages}.
+ *
+ * `__proto__`, `constructor` and `prototype` are the three steps of prototype
+ * pollution. `pickMessages` refuses them too - it is a shared utility and does
+ * not get to assume its caller checked - but they are rejected here rather than
+ * quietly dropped, because a request asking for one is not a request with a
+ * typo in it.
+ */
+const UNSAFE_NAMESPACE_SEGMENTS: ReadonlySet = new Set([
+ '__proto__',
+ 'constructor',
+ 'prototype',
+])
+
+/**
+ * One namespace, or an error.
+ *
+ * Deliberately says *what* was wrong and not *what was sent*: the value is
+ * attacker-controlled and this message ends up in a server log.
+ */
+const assertNamespace = (value: unknown, index: number): string => {
+ const at = `namespaces[${index}]`
+
+ if (typeof value !== 'string') throw new Error(`${at} must be a string.`)
+ if (value.length === 0) throw new Error(`${at} must not be empty.`)
+ if (value.length > MAX_NAMESPACE_LENGTH) {
+ throw new Error(`${at} must be at most ${MAX_NAMESPACE_LENGTH} characters.`)
+ }
+
+ const segments = value.split('.')
+
+ if (segments.length > MAX_NAMESPACE_DEPTH) {
+ throw new Error(`${at} must be at most ${MAX_NAMESPACE_DEPTH} segments.`)
+ }
+
+ for (const segment of segments) {
+ // `core..global`, a leading dot, a trailing dot - all malformed, and all
+ // of them paths that would walk somewhere nobody meant.
+ if (segment.length === 0) {
+ throw new Error(`${at} must not contain an empty segment.`)
+ }
+ if (UNSAFE_NAMESPACE_SEGMENTS.has(segment)) {
+ throw new Error(`${at} contains a forbidden segment.`)
+ }
+ }
+
+ return value
+}
+
+/**
+ * What the server function will accept.
+ *
+ * Everything below treats the argument as arriving from the network, because
+ * once this app is built that is exactly what it does: a server function is a
+ * public `POST` endpoint, and nothing about the client that normally calls it is
+ * enforceable.
+ *
+ * The locale is the one field that degrades rather than fails. A stale link to a
+ * language that has since been removed should still render the page in the
+ * default language; being strict there would turn a config change into a 500 on
+ * every old URL. A namespace, by contrast, is only ever sent by this app's own
+ * code, so anything unexpected is rejected outright - filtering it away silently
+ * would hide the fact that something is sending it.
+ *
+ * Exported for the tests: a server function cannot be invoked outside a request
+ * scope, so the only way to exercise the boundary directly is to call the
+ * function that guards it.
+ */
+export const validateIntlInput = (input: unknown) => {
+ if (typeof input !== 'object' || input === null) {
+ throw new Error('Expected an object.')
+ }
+
+ const { locale, namespaces } = input as {
+ locale?: unknown
+ namespaces?: unknown
+ }
+
+ if (typeof locale !== 'string') throw new Error('locale must be a string.')
+ if (!Array.isArray(namespaces)) {
+ throw new Error('namespaces must be an array.')
+ }
+ // Checked before validating each entry, so a caller cannot make the server
+ // walk an arbitrarily long list just to be told the list was too long.
+ if (namespaces.length > MAX_NAMESPACES) {
+ throw new Error(`At most ${MAX_NAMESPACES} namespaces may be requested.`)
+ }
+
+ return {
+ locale: localeRouting.isSupportedLocale(locale)
+ ? locale
+ : localeRouting.defaultLocale,
+ // `Array.from` rather than `map`: `map` skips holes in a sparse array, so
+ // an entry could reach normalisation without ever being validated. This
+ // visits them as `undefined`, which `assertNamespace` rejects.
+ namespaces: normalizeNamespaces(Array.from(namespaces, assertNamespace)),
+ }
+}
+
+/**
+ * One language's messages for one set of namespaces, fetched on the server.
+ *
+ * A server function rather than a plain loader: the messages are read from JSON
+ * inside each package's `dist`, which only exists on the server, and the plugin
+ * registry they are merged from must never reach the browser bundle. Start
+ * strips the handler - and everything only it imports - out of the client build.
+ */
+export const getIntlMessages = createServerFn()
+ .validator(validateIntlInput)
+ .handler(async ({ data }) => await loadIntlMessages(data))
+
+/**
+ * The same request, as a query - and the only way the app should ask for it.
+ *
+ * The locale is a required argument and part of the key. That is the whole
+ * point: two languages coexist in one QueryClient, a language switch changes the
+ * key rather than the value under it, and nothing ever resolves "the current
+ * locale" from inside a query function, where it would be whatever the last
+ * render happened to leave behind.
+ *
+ * `staleTime: Infinity` - a locale's messages change when the app is redeployed.
+ */
+export const intlQueryOptions = ({
+ locale,
+ namespaces = [GLOBAL_NAMESPACE],
+}: {
+ locale: Locale
+ namespaces?: readonly string[]
+}) => {
+ const normalized = normalizeNamespaces(namespaces)
+
+ return queryOptions({
+ queryFn: async () =>
+ await getIntlMessages({ data: { locale, namespaces: normalized } }),
+ queryKey: ['vitnode', 'intl', locale, ...normalized] as const,
+ staleTime: Infinity,
+ })
+}
diff --git a/apps/web/src/lib/i18n/shared.ts b/apps/web/src/lib/i18n/shared.ts
new file mode 100644
index 000000000..e9f9351b6
--- /dev/null
+++ b/apps/web/src/lib/i18n/shared.ts
@@ -0,0 +1,31 @@
+import type { LocaleRouting } from '@vitnode/core/lib/i18n/locale-routing'
+
+import { localeRoutingFromConfig } from '@vitnode/core/lib/i18n/locale-routing'
+
+import { i18n } from '#/i18n'
+
+/**
+ * A language this app serves, as a type. `"en" | "pl"`, derived from the config
+ * rather than written twice.
+ */
+export type Locale = (typeof i18n.locales)[number]['code']
+
+/**
+ * How this app's URLs carry a language, and the only place that decides it.
+ *
+ * Pure string transforms, built from `src/i18n.ts` - no `Request`, no cookies,
+ * no router, no `window`. That is what lets the same rules run in four places
+ * that cannot import each other's runtimes: the server middleware that
+ * canonicalises incoming URLs, the router rewrite that hides the prefix from the
+ * route tree, the language switcher in the browser, and the tests.
+ *
+ * `/admin` and `/api` are outside all of it - see `DEFAULT_IGNORED_LOCALE_PATHS`
+ * in core for why - and this app takes that default as-is.
+ */
+export const localeRouting: LocaleRouting = localeRoutingFromConfig(i18n)
+
+export const { defaultLocale } = localeRouting
+
+/** Narrows a string - a URL segment, a cookie, a `