CoreMVP

Localize a Public Page

View as Markdown

Give a public page an explicit URL per language: /product/en, /product/fr and /product/es. First create the Counter from Translate Components; the public page supplies its own provider and catalogs, independent of the dashboard.

Next.js only

This tutorial is for the Next.js template. TanStack Start has no public URL locale tutorial yet; use Translate Components for the dashboard.

Build the page

Create the directories named in the following file titles. Keep the dashboard configuration and catalogs intact.

Define public languages

src/i18n/product-locales.ts
export const locales = ["en", "fr", "es"] as const;
export type Locale = (typeof locales)[number];
export const localeNames: Record<Locale, string> = {
  en: "English", fr: "Français", es: "Español",
};
export function isLocale(value: string): value is Locale {
  return locales.some((locale) => locale === value);
}

All three URLs have a language segment, including English. This differs from the showcase's omitted English prefix.

Create independent public catalogs

src/i18n/product-messages/en.json
{
  "Product": {
    "title": "Your workspace",
    "language": "Language"
  },
  "Counter": {
    "add": "Add an item",
    "items": "{count, plural, =0 {No items} one {# item} other {# items}}"
  }
}

Create French and Spanish catalogs with the same keys:

src/i18n/product-messages/fr.json
{
  "Product": {
    "title": "Votre espace de travail",
    "language": "Langue"
  },
  "Counter": {
    "add": "Ajouter un élément",
    "items": "{count, plural, =0 {Aucun élément} one {# élément} other {# éléments}}"
  }
}
src/i18n/product-messages/es.json
{
  "Product": {
    "title": "Tu espacio de trabajo",
    "language": "Idioma"
  },
  "Counter": {
    "add": "Añadir un elemento",
    "items": "{count, plural, =0 {Ningún elemento} one {# elemento} other {# elementos}}"
  }
}

These are separate files from . Keep their keys and placeholders consistent; the loader below checks their required shape.

Load messages for the explicit locale

src/i18n/product-messages.ts
import "server-only";
import { createTranslator } from "next-intl";
import type { Locale } from "./product-locales";
import en from "./product-messages/en.json";
import fr from "./product-messages/fr.json";
import es from "./product-messages/es.json";

export type Messages = typeof en;
const catalogs: Record<Locale, Messages> = { en, fr, es };
export function getMessages(locale: Locale) { return catalogs[locale]; }
export function getProductTranslator(locale: Locale) {
  return createTranslator({ locale, messages: getMessages(locale), namespace: "Product" });
}

The locale is a per-request argument, never mutable global state. This standalone translator uses the URL locale rather than the dashboard's request configuration. To add another public language later, update , create its complete product-messages/<locale>.json and register it here.

Supply client context explicitly

src/app/product/[locale]/translations.tsx
"use client";
import { NextIntlClientProvider } from "next-intl";
import type { ReactNode } from "react";
import type { Locale } from "@/i18n/product-locales";
import type { Messages } from "@/i18n/product-messages";

export function Translations({ locale, messages, children }: {
  locale: Locale;
  messages: Pick<Messages, "Counter">;
  children: ReactNode;
}) {
  return (
    <NextIntlClientProvider locale={locale} messages={messages} timeZone="UTC">
      {children}
    </NextIntlClientProvider>
  );
}

The client wrapper receives its locale, messages, and time zone explicitly. Its type-only imports keep the server catalog loader out of the client bundle. Only public counter text will be sent to the browser.

Connect the route

src/app/product/[locale]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { Counter } from "@/components/translation-example/counter";
import { isLocale, locales, localeNames } from "@/i18n/product-locales";
import { getMessages, getProductTranslator } from "@/i18n/product-messages";
import { Translations } from "./translations";

export default async function ProductPage({ params }: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!isLocale(locale)) notFound();
  const t = getProductTranslator(locale);
  const messages = getMessages(locale);
  return (
    <main lang={locale} className="mx-auto max-w-xl space-y-6 p-6">
      <h1>{t("title")}</h1>
      <nav aria-label={t("language")} className="flex gap-4">
        {locales.map((code) => (
          <Link key={code} href={`/product/${code}`} hrefLang={code}
            aria-current={code === locale ? "page" : undefined}>
            {localeNames[code]}
          </Link>
        ))}
      </nav>
      <Translations locale={locale} messages={{ Counter: messages.Counter }}>
        <Counter />
      </Translations>
    </main>
  );
}

Validate before loading messages: unsupported locales return 404. Next.js dynamic parameters are asynchronous. Language links navigate so the server and client change together; changing only client state cannot update the server heading.

Check the URL boundary

With the app running, open /product/es on your configured local origin, even with a French dashboard preference. Expect Tu espacio de trabajo, Ningún elemento, and Añadir un elemento. Click for 1 elemento and 2 elementos. Follow Français and English; heading and counter must agree. Reload each URL: its language persists, while the counter resets to zero.

/product/de must return 404. Run bun run test, then bunx tsc --noEmit for catalog shape and props. Typechecking does not validate every ICU message or translation; neither included E2E suite automatically discovers this new route.

What's next

Was this page helpful?

On this page