Frontend I18n

This page adds English and Chinese pages to a full-stack app with frontend enabled. The server explicitly chooses the locale so SSR and the first hydration agree. These two languages are example application data, separate from translation of this documentation site.

Table of Contents

Locale Sources

Backend config.locale negotiates the request language and fills req.locale; frontend.i18n controls frontend message scanning and loading. With frontend i18n enabled, HTML, page envelopes, and browser navigation share a concrete effective locale. "inherit" never becomes a dictionary key or HTML language.

Set frontend.i18n.enabled=false when the frontend should not scan or bundle src/frontend/locales/**. You can still pass explicit messages through res.render() for one-off page data.

When scanning is disabled, useVextI18n() returns an empty object unless the route supplies explicit messages. The generated Vext starter keeps its launchpad usable with neutral English fallbacks, but application components should provide their own fallback before reading nested copy:

// Inside a component; import useVextI18n from vextjs/frontend.
const i18n = useVextI18n<{ dashboard?: { title: string } }>();
const dashboard = i18n.dashboard ?? { title: "Dashboard" };

Locale selection uses this priority:

  1. A concrete res.render(..., { locale }) override.
  2. With defaultLocale: "inherit", a req.locale matching available frontend dictionaries.
  3. Detection in configured order, falling back to a concrete default locale, the first available dictionary, or en-US.

detect supports query (query.locale), header / x-vext-locale (X-Vext-Locale), cookie (the locale cookie), and accept-language. Accept-Language follows quality weights with case-insensitive and language-prefix matching. Unsupported sources fail configuration validation. Routes explicitly handle path prefixes, user preferences, and authorization-dependent choices.

For request-side inheritance, configure locale.supported: ["en-US", "zh-CN"]. Without supported request locales, the backend uses its default en-US; a matching inherited locale takes priority over frontend detection. Set a concrete frontend default locale to use the frontend detection list independently.

res.render() includes the effective locale and explicit messages in the payload and navigation envelope. With htmlLang enabled, SSR and later navigation update <html lang>; htmlLang: false removes the generated language marker.

inject: "used" does not yet trim messages per component and emits a build diagnostic. The selected language's complete messages are loaded; clientLoad: "current" limits the number of languages. clientSwitch: "reload" does not create a switching UI; implement the switching steps below.

Frontend Locale Files

Put page copy under src/frontend/locales/**.

The suggested layout groups messages by feature, with further subdirectories when useful. Override the scan location with frontend.i18n.source:

src/frontend/locales/
  dashboard/
    en-US.ts
    zh-CN.ts
  order/
    payment/
      en-US.json
      zh-CN.json

For example, { "title": "Payment" } in order/payment/en-US.json is available as i18n.order.payment.title. Directories form object namespaces. Existing single-file dictionaries and nested object access remain supported. Extensions are .ts/.mts/.cts/.js/.mjs/.cjs/.json. A locale may have several modules, but normalized locale/directory pairs and final message keys must be unique.

Top-level fully qualified dotted keys and numeric business codes retain their original keys, accessed with brackets such as i18n["order.payment.title"]. Do not define the same final message through both directory names and a fully qualified key. Frontend dictionaries retain object structure; backend validators consume flattened message keys.

The single-file layout remains supported too. The following demo namespace avoids overwriting starter messages; create both files:

// src/frontend/locales/demo/en-US.ts
export default {
  title: "Dashboard",
  welcome: "Welcome back",
};
// src/frontend/locales/demo/zh-CN.ts
export default {
  title: "仪表盘",
  welcome: "欢迎回来",
};

The locale files for one module should keep the same object shape. Missing keys are content errors; a generic type declaration does not supply runtime messages.

Use Messages in Components

Vext's default frontend API is object access, not t("a.b.c").

// src/frontend/pages/localized-dashboard.tsx
import { useVextI18n } from "vextjs/frontend";

type Messages = { demo: { title: string; welcome: string } };

export default function DashboardPage() {
  const i18n = useVextI18n<Messages>();
  return (
    <main>
      <h1>{i18n.demo.title}</h1>
      <p>{i18n.demo.welcome}</p>
      <a href="/language/en-US">English</a>
      <a href="/language/zh-CN">中文</a>
    </main>
  );
}

Define the corresponding route, validate the locale, and pass it explicitly:

// src/routes/language.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get(
    "/:locale",
    { validate: { param: { locale: "string" } } },
    (req, res) => {
      const locale = req.valid("param").locale;
      if (locale !== "en-US" && locale !== "zh-CN")
        app.throw(404, "Unknown locale");
      res.render("localized-dashboard", {}, { locale });
    },
  );
});

When the component knows that a language is already loaded on both server and browser, it can request that language with the same Messages type:

const english = useVextI18n<Messages>("en-US");

The hook reads context; it does not download messages or change the global locale. If the requested locale is absent from allMessages, it returns the current messages. In current mode, do not force-read an unloaded language on another language's page: different server and browser message sets can cause a hydration mismatch. Explicit messages replace the current message object rather than deeply merging into it.

SSR and Hydration

Merge this into src/config/default.ts. The parser defaults frontend i18n to disabled, while the full-stack starter enables it explicitly. This config also supplies a concrete fallback and the default loading mode:

export default {
  frontend: {
    enabled: true,
    i18n: {
      enabled: true,
      defaultLocale: "en-US",
      clientLoad: "current",
    },
  },
};

"current" means the browser loads only the SSR locale for hydration. This keeps the first JS payload smaller.

If the browser needs to read other built languages, change that clientLoad value to "all". This fragment assumes the rest of the config above:

frontend: {
  i18n: {
    clientLoad: "all",
  },
}

Switch Language

all loads all scanned languages but does not automatically create a selector, store a preference, change HTML lang, or rerender the page. The app still implements those actions. The simplest complete flow is reload-based:

  1. User chooses a language.
  2. Store it in a cookie, user preference API, URL prefix, or another server-visible location.
  3. Navigate or reload the page.
  4. SSR and hydration use the same locale.

Avoid making the server render one locale while the browser immediately renders another during hydration.

Cache and Vary

If HTML can vary by language, cache keys must include language.

Common strategies:

  • keep Vary: Accept-Language
  • include locale in CDN key
  • use locale path prefixes such as /en and /zh
  • include language preference cookie in the reverse-proxy key

The /language/en-US and /language/zh-CN URLs in this example separate languages by URL. If using Accept-Language or a cookie, include the final rendered language in the cache key and set Vary for the actual source. Current frontend.i18n.vary does not implement those settings for you. The page-protocol Vary is also separate from a language Vary; see Render Data and Cache.

The example uses /language/en-US and /language/zh-CN to distinguish languages in the URL. With i18n.vary: true, detection merges the relevant Accept-Language, X-Vext-Locale, or Cookie into existing Vary. URL-only selection needs no language header. When false, the app must isolate external cache variants.

Frontend freshness uses the same effective locale in its key. Cookie, authenticated/session-bearing requests, custom req.locale, and render overrides not represented by declared request sources or URL bypass public caching. Static/revalidate routes must declare language inputs through detect or URL; use dynamic for undeclared header or identity-dependent variation. Results with a render locale different from the lookup locale are not stored under that key. See Render Data and Cache.

Generated JS/CSS and locale chunks usually use content-hashed URLs. Caching those files is different from caching HTML that varies by language.

Verify the Example

Run npm run build, then npm start -- --port 3000. Visit both language URLs. Raw HTML, the heading, HTML lang, and text after hydration should agree with each locale, with no Console mismatch. The links should navigate fully to the other language. /language/unknown should return 404. Changing Accept-Language must not override the locale explicitly selected by this URL example. Stop the server when finished.