Internationalization

View as Markdown

Neutron's i18n utilities handle locale-prefixed URLs: resolving the locale from a pathname, redirecting to canonical paths, and building locale-aware links.

This is routing only. Neutron does not include a translation system — there is no message catalogue, no translation lookup, and no formatting or pluralisation helpers. Locale resolution is URL-based; the Accept-Language header is not consulted.

Setup

Add the i18n middleware in your src/middleware.ts file.

// src/middleware.ts
import { createI18nMiddleware } from "@neutron-build/core";

export const middleware = [
  createI18nMiddleware({
    locales: ["en", "es", "fr"],
    defaultLocale: "en",
  }),
];

The middleware resolves the locale from the URL on every request and puts two values on the request context:

  • context.locale — the resolved locale (for example "es")
  • context.pathWithoutLocale — the pathname with the locale prefix removed (for example /docs)

Strategies

The default strategy is prefix-except-default: every locale is prefixed except the default one.

| URL | Resolved locale | Redirect | | -------------- | --------------- | ---------------- | | /pricing | en | — | | /es/pricing | es | — | | /en/pricing | en | 307 → /pricing |

Set strategy: "prefix" to prefix every locale, including the default:

createI18nMiddleware({
  locales: ["en", "es"],
  defaultLocale: "en",
  strategy: "prefix",
});

| URL | Resolved locale | Redirect | | -------------- | --------------- | ------------------------- | | /pricing | en | 307 → /en/pricing | | /en/pricing | en | — | | /es/pricing | es | — |

Redirects are issued for GET and HEAD requests only. The middleware also runs before prebuilt (static) pages are served, so the canonical redirects apply to them too.

Reading the Locale in a Loader

The context values set by the middleware arrive in your loader (and action).

// src/routes/[...slug].tsx
import type { LoaderArgs } from "@neutron-build/core";

export async function loader({ context }: LoaderArgs) {
  const locale = context.locale as string;
  const path = context.pathWithoutLocale as string;

  return { locale, path };
}

The example uses a catch-all route because of the next section.

Route Matching Is Not Rewritten

The middleware annotates the context and issues redirects — it does not rewrite the URL before routing. Route matching sees the full pathname, prefix included, so /es/pricing needs a route that matches it (a [locale] path segment or a catch-all such as [...slug]) or the request 404s.

withLocalePath prefixes a path with a locale according to your strategy. In prefix-except-default, the default locale gets no prefix.

import { withLocalePath } from "@neutron-build/core";

const options = {
  locales: ["en", "es", "fr"],
  defaultLocale: "en",
};

withLocalePath("/pricing", "es", options); // "/es/pricing"
withLocalePath("/pricing", "en", options); // "/pricing"
withLocalePath("/", "es", options); // "/es"

It throws if the locale is not in locales.

Resolving Paths Directly

resolveLocalePath returns everything the middleware derives from a pathname.

import { resolveLocalePath, stripLocalePrefix } from "@neutron-build/core";

const options = {
  locales: ["en", "es", "fr"],
  defaultLocale: "en",
};

resolveLocalePath("/es/pricing", options);
// {
//   locale: "es",
//   pathname: "/es/pricing",
//   pathWithoutLocale: "/pricing",
//   hasLocalePrefix: true
// }

stripLocalePrefix("/es/pricing", options); // "/pricing"

Both resolveLocalePath and withLocalePath throw if defaultLocale is not in locales.

What Is Not Included

  • No message catalogue or translation lookup — there is no t() function.
  • No formatting or pluralisation for dates, numbers, or plurals.
  • No locale negotiation from request headers — resolution is purely URL-based.
  • No URL rewriting for route matching — see Route Matching Is Not Rewritten.

If you need translated content, pair this module with a translation library and look messages up by context.locale in your loaders.