Localize a Public Page
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
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
{
"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:
{
"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}}"
}
}{
"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
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
"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
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
Public Page SEO
Disable or Remove i18n
Was this page helpful?