Back to Journal

Making the default locale env-configurable

Listen to this articleAI narration
0:00 / 0:00

The client runs a multilingual Next.js site with three locales — Swedish, English, and Danish — built on next-intl. Swedish is the primary language. The default locale was hardcoded to "sv" in two separate files, and changing it required a code change and a redeploy. The ask: make it configurable through an environment variable so the default can shift between environments without touching source code.

Straightforward on paper. One gotcha after deployment.

The Starting Point

The i18n setup followed the standard next-intl pattern for App Router. Two files owned the locale configuration. First, i18n.ts — the core config that next-intl's plugin loads at build time:

export const locales = ["sv", "en", "da"] as const;
export type Locale = (typeof locales)[number];
 
export const defaultLocale: Locale = "sv";

Second, navigation.ts — the routing definition consumed by the middleware:

export const routing = defineRouting({
  locales,
  defaultLocale: "sv",
  localePrefix: "always",
  alternateLinks: false,
});

Two files, two hardcoded "sv" strings. The defaultLocale in i18n.ts was exported and used as a fallback when the request's locale parameter is missing or invalid. The one in navigation.ts told the middleware where to send visitors who arrive without a locale prefix. Both needed to agree.

Before: Two Hardcoded Sources of Truthi18n.tsdefaultLocale: Locale = "sv"Fallback for invalid/missing localenavigation.tsdefaultLocale: "sv"Middleware routing targetChanging locale = edit two files + redeployBoth must stay in sync — easy to drift

The duplication was the first problem. If someone updated i18n.ts but forgot navigation.ts, the middleware would route to one locale while the server-side config fell back to another.

Wiring the Environment Variable

The fix was to make i18n.ts the single source of truth and have navigation.ts import from it. The defaultLocale reads from process.env with a fallback:

// i18n.ts
export const defaultLocale: Locale =
  (process.env.NEXT_PUBLIC_DEFAULT_LOCALE as Locale) || "sv";

And navigation.ts stopped owning its own value:

// navigation.ts
import { locales, defaultLocale } from "./i18n";
 
export const routing = defineRouting({
  locales,
  defaultLocale,
  localePrefix: "always",
  alternateLinks: false,
});

The NEXT_PUBLIC_ prefix is deliberate. next-intl's routing runs both server-side in the middleware and client-side through createNavigation. Without the prefix, Next.js strips the variable from the client bundle and the navigation helpers fall back to "sv" regardless of what the server resolved.

The .env file got one new line:

NEXT_PUBLIC_DEFAULT_LOCALE=sv
After: Single Source via EnvironmentNEXT_PUBLIC_DEFAULT_LOCALE=sv.env / hosting provider dashboardi18n.ts — single source of truthprocess.env.NEXT_PUBLIC_DEFAULT_LOCALE || "sv"navigation.tsimports defaultLocalemiddleware.tsimports routing config

Deployed. Opened the site. Landed on /en.

The Browser Override

next-intl's middleware does more than just check the URL for a locale prefix. When a visitor arrives at the root path without a locale, it reads the Accept-Language header from the browser, matches it against the supported locales, and redirects accordingly. My browser is set to English. The middleware saw en in the header, matched it against the locales array, and sent me to /en — completely ignoring the configured defaultLocale.

This is localeDetection at work. It's enabled by default in next-intl and it's usually the right behavior for multilingual sites that want to greet visitors in their preferred language. But for this site, Swedish is the canonical locale. The site's content, brand, and primary audience are Swedish. Visitors should land on /sv and switch manually if needed.

Middleware Locale Detection FlowVisitor hits / (no locale prefix)localeDetection enabled?YesNoRead Accept-Language headerAccept-Language: en-US,en;q=0.9→ redirect to /enUse defaultLocaleNEXT_PUBLIC_DEFAULT_LOCALE=sv→ redirect to /sv

One line in the routing config:

export const routing = defineRouting({
  locales,
  defaultLocale,
  localePrefix: "always",
  localeDetection: false,
  alternateLinks: false,
});

With localeDetection: false, the middleware stops reading Accept-Language. First-time visitors always land on whatever defaultLocale resolves to. The language switcher still works — users can navigate to /en or /da manually, and next-intl respects the locale prefix in the URL for all subsequent navigation. It just stops guessing on the initial visit.

Files Changed

Three files total. i18n.ts gained the process.env read with the "sv" fallback. navigation.ts dropped its hardcoded "sv" in favor of importing defaultLocale from i18n.ts and added localeDetection: false. The .env file got NEXT_PUBLIC_DEFAULT_LOCALE=sv.

The change itself was small. The lesson was that next-intl locale resolution is a two-step process — first the middleware decides where to route, then the server config decides what to render. The defaultLocale setting only controls the second step. The first step has its own opinion, and unless you explicitly silence it, browser preferences win. A correctly configured default locale that nobody ever sees because the middleware overrides it before the page loads — that's the kind of thing you only discover in production with a browser that isn't set to the default language.