Domphy

i18n Routing

@domphy/app handles locale-prefix URL routing via middleware. Translations and locale state use @domphy/i18n.

Setup

import { createApp, createI18nMiddleware, getLocale } from "@domphy/app"
import { createI18n } from "@domphy/i18n"
import en from "./locales/en.json"
import vi from "./locales/vi.json"

export const i18nOpts = {
  locales: ["en", "vi"] as const,
  defaultLocale: "en" as const,
} satisfies { locales: readonly string[]; defaultLocale: string }

export const i18n = createI18n({
  globalKey: "__myapp_i18n__",
  namespace: "app",
  locales: { en, vi },
  defaultLocale: "en",
})

const app = createApp(routes, {
  middleware: [createI18nMiddleware(i18nOpts)],
})

How It Works

createI18nMiddleware intercepts every navigation before routing:

URLActionRoute rendered
/vi/aboutrewrite to /aboutabout route with locale "vi"
/en/aboutrewrite to /aboutabout route with locale "en"
/aboutpass throughabout route with defaultLocale ("en")

Your page components and loaders don't need to know about the locale prefix — they always receive the bare pathname.

Reading the Active Locale

Use getLocale(context, opts) in any loader or page to read the locale from the original URL:

{
  path: "about",
  loader: async (ctx) => {
    const locale = getLocale(ctx, i18nOpts) // "en" | "vi"
    await i18n.initI18n(locale)
    return locale
  },
  page: (ctx) => ({
    h1: (l) => i18n.t(l, "about.title"),
  }),
}

getLocale reads the first URL segment from context.url (the original URL before the middleware rewrite) and matches it against options.locales. Falls back to defaultLocale when no prefix is present.

Prefix Default Locale

By default, the default locale has no URL prefix (/about"en"). To prefix every locale (Next.js locales with defaultLocale):

createI18nMiddleware({ ...i18nOpts, prefixDefault: true })

With prefixDefault: true, a bare /about redirects to /en/about.

Options

createI18nMiddleware(options)

OptionTypeDefaultDescription
localesreadonly TLocale[]requiredAll supported locale codes
defaultLocaleTLocalerequiredLocale to use when no prefix is detected
prefixDefaultbooleanfalseWhether to prefix the default locale in the URL

Returns a Middleware to pass in createApp(routes, { middleware: [...] }).

getLocale(context, options)

ParamTypeDescription
context{ url: string }Any loader or route context
options.localesreadonly TLocale[]Must match the middleware options
options.defaultLocaleTLocaleFallback when no prefix detected

Returns TLocale.

Switching Locale

Switching locale is a navigation to the locale-prefixed URL:

function LocaleSwitcher() {
  const router = getRouter()
  return {
    div: [
      {
        button: "English",
        onClick: () => router.push(`/en${router.state.get("pathname")}`),
      },
      {
        button: "Tiếng Việt",
        onClick: () => router.push(`/vi${router.state.get("pathname")}`),
      },
    ],
  }
}