# Localize a Public Page (/en/docs/internationalization/localize-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](/en/docs/internationalization/components); the public
page supplies its own provider and catalogs, independent of the dashboard.

<Callout type="info" title="Next.js only">
  This tutorial is for the Next.js template. TanStack Start has no public URL
  locale tutorial yet; use [Translate Components](/en/docs/internationalization/components)
  for the dashboard.
</Callout>

## Build the page

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

<Steps>
  <Step>
    ### Define public languages

    ```typescript title="src/i18n/product-locales.ts" interactive=false
    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.
  </Step>

  <Step>
    ### Create independent public catalogs

    ```json title="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:

    ```json title="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}}"
      }
    }
    ```

    ```json title="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 `src/i18n/messages/`. Keep their keys and
    placeholders consistent; the loader below checks their required shape.
  </Step>

  <Step>
    ### Load messages for the explicit locale

    ```typescript title="src/i18n/product-messages.ts" interactive=false
    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
    `product-locales.ts`, create its complete `product-messages/<locale>.json`
    and register it here.
  </Step>

  <Step>
    ### Supply client context explicitly

    ```tsx title="src/app/product/[locale]/translations.tsx" interactive=false
    "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.
  </Step>

  <Step>
    ### Connect the route

    ```tsx title="src/app/product/[locale]/page.tsx" interactive=false
    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](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes)
    are asynchronous. Language links navigate so the server and client change
    together; changing only client state cannot update the server heading.
  </Step>

  <Step>
    ### 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.
  </Step>
</Steps>

## What's next

<Cards>
  <Card title="Public Page SEO" href="/en/docs/public-page-seo" description="Set translated titles and canonical URLs before publishing." />

  <Card title="Disable or Remove i18n" href="/en/docs/internationalization/remove-i18n" description="Keep one language and remove the rest." />
</Cards>
