Nuxt i18n Module Setup

@nuxtjs/i18n does four jobs that are usually separate concerns: it generates a localized route for every page, detects the reader’s locale, loads the right message file, and writes the language metadata into the document head. Configured well, a Nuxt application gets working localized URLs, correct hreflang annotations and lazy message loading from about thirty lines of configuration.

Configured badly, it produces the two failures every Nuxt project hits: a redirect loop between the prefixed and unprefixed forms of the same page, and a language switcher that drops the reader back on the home page. Both come from the same root — treating locale as something each layer can decide for itself.

The Nuxt i18n request pipeline A request is matched against a localized route, the module derives the locale from the URL prefix and then the cookie, the message file for that locale is loaded lazily, and the server renders with the correct lang and dir attributes and the alternate-language head links. How @nuxtjs/i18n resolves one request Route match /de/produkte locale Locale detected prefix, then cookie messages Messages loaded lazy, per locale render SSR + head lang, dir, hreflang Detection runs once, on the server. The client receives a locale, never a decision to make.
Each stage hands one concrete value forward — the client never re-derives the locale.

This page belongs to Framework i18n & Component Routing and assumes the message layer described in Vue i18n composition API guide, since the module wraps vue-i18n rather than replacing it.

Prerequisites

Concept & spec — the module is a route generator first

The mental model that makes everything else fall into place: @nuxtjs/i18n is primarily a route generator. For every page in your pages/ directory it emits one route per locale, according to the strategy you chose. Everything else — detection, message loading, head tags — hangs off which of those routes was matched.

That is why the routing strategy is the decision to make first and change never. It determines your URL shape, and URLs are the one part of an application you cannot refactor freely once they are indexed and shared.

Nuxt i18n routing strategies compared With prefix_except_default the default locale lives at the bare path and others are prefixed, which suits a product with one home market. With prefix every locale is prefixed, which suits equal markets. prefix_and_default serves both forms and needs a canonical to disambiguate, which suits a URL migration. no_prefix keeps one URL for every locale and gives up shareable localized links. The four routing strategies Default locale URL Other locales Use when prefix_except_default /about /de/about one home market prefix /en/about /de/about equal markets prefix_and_default both, canonical set /de/about migrating URLs no_prefix /about /about cookie-only switching
The last row is the only one where a shared link cannot carry its language — which is usually disqualifying.

prefix_except_default is the common choice for a product with a clear home market: the default locale lives at /about and everything else at /de/about. It keeps existing URLs stable when localization is added to an established site, which is usually the deciding factor.

prefix gives every locale a prefix including the default. It is the cleaner model — every URL is explicitly a locale’s URL, nothing is implicit, and adding a locale later changes nothing about existing paths. Choose it for a new product with several equally important markets.

prefix_and_default serves both forms and exists mainly for migrations. Two URLs then render the same content, so a canonical annotation is mandatory rather than optional, and forgetting it is a duplicate-content problem covered in locale-aware SEO and hreflang.

no_prefix keeps a single URL set and switches locale by cookie alone. It is simple and it gives up the property that makes localized URLs worth having: a shared link cannot carry its language, and a crawler cannot discover the other locales at all.

The four configuration surfaces

A recurring source of confusion is that Nuxt i18n options and vue-i18n options live in different files and are not interchangeable.

The four configuration surfaces of Nuxt i18n The Nuxt config declares locales, the routing strategy and browser-language detection at build time. A separate i18n config file carries the vue-i18n runtime options such as fallback locale and date formats. Locale JSON files hold the messages. Page-level meta supplies per-route overrides and custom localized paths. Where each piece of configuration lives nuxt.config.ts locales, strategy, detectBrowserLanguage build time i18n.config.ts vue-i18n options: fallback, datetimeFormats runtime locales/*.json the messages themselves per locale definePageMeta per-page overrides and custom paths per route
The second row is the one teams miss — vue-i18n options do not belong in nuxt.config.

Module options — the locale list, the strategy, detection behaviour, lazy loading — belong in nuxt.config.ts because they affect route generation at build time. vue-i18n options — fallbackLocale, datetimeFormats, numberFormats, missingWarn — belong in a separate i18n.config.ts that the module loads at runtime. Putting a vue-i18n option in the Nuxt config silently does nothing, which is a difficult failure to diagnose because nothing warns.

Step-by-step implementation

1. Declare the locales with full metadata

Each locale entry should carry its BCP 47 tag, its message file, its writing direction and the name to show in a switcher. The language field is what ends up in hreflang and in the lang attribute, so it must be a valid tag rather than a display name.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    strategy: 'prefix_except_default',
    defaultLocale: 'en',
    locales: [
      { code: 'en', language: 'en-GB', name: 'English',  file: 'en.json', dir: 'ltr' },
      { code: 'de', language: 'de-DE', name: 'Deutsch',  file: 'de.json', dir: 'ltr' },
      { code: 'ar', language: 'ar-EG', name: 'العربية', file: 'ar.json', dir: 'rtl' },
    ],
    lazy: true,
    langDir: 'locales/',
    baseUrl: 'https://example.com',   // required for absolute hreflang URLs
  },
});

2. Configure detection so it never fights the URL

The default detection behaviour redirects on the root path and remembers the choice in a cookie. Two settings matter: redirectOn: 'root' confines the redirect to the entry point, so a prefixed URL is never redirected again, and alwaysRedirect: false means an explicit URL always wins over the cookie.

detectBrowserLanguage: {
  useCookie: true,
  cookieKey: 'i18n_redirected',
  redirectOn: 'root',        // never redirect an already-localized path
  alwaysRedirect: false,     // an explicit URL beats a remembered preference
  fallbackLocale: 'en',
},

Setting redirectOn: 'all' is the single most common cause of the redirect loop, because it invites the middleware to reconsider a decision the URL has already expressed.

3. Put vue-i18n options in their own file

// i18n.config.ts
export default defineI18nConfig(() => ({
  legacy: false,
  fallbackLocale: { 'de-AT': ['de'], default: ['en'] },
  missingWarn: process.env.NODE_ENV !== 'production',
  numberFormats: {
    'de-DE': { currency: { style: 'currency', currency: 'EUR' } },
    'en-GB': { currency: { style: 'currency', currency: 'GBP' } },
  },
}));

4. Build a switcher that preserves the route

switchLocalePath resolves the equivalent path in the target locale, carrying dynamic parameters and any custom localized path across. Hand-building the URL by swapping a prefix is what sends readers to the home page, because it cannot know that /en/products/42 is /de/produkte/42.

<script setup lang="ts">
const { locales, locale } = useI18n();
const switchLocalePath = useSwitchLocalePath();
</script>

<template>
  <nav aria-label="Language">
    <NuxtLink
      v-for="l in locales"
      :key="l.code"
      :to="switchLocalePath(l.code)"
      :hreflang="l.language"
      :aria-current="l.code === locale ? 'true' : undefined"
    >{{ l.name }}</NuxtLink>
  </nav>
</template>
The four responsibilities of a locale switcher Choosing a language must resolve the equivalent localized path rather than a hardcoded prefix, carry the current route parameters across so the reader stays on the same resource, persist the choice in the redirect cookie so detection does not override it next visit, and navigate client-side so only the new message file is fetched. What a locale switcher must do Reader picks Deutsch from /en/products/42 Resolve the localized path switchLocalePath("de") Keep the route params /de/produkte/42 Persist the choice i18n_redirected cookie Navigate, not reload messages load lazily
Dropping any one of these produces the same complaint: "it sent me back to the home page".

5. Emit the head metadata from the module

useLocaleHead generates the lang attribute, the writing direction and the reciprocal hreflang links from the locale list, which is far more reliable than maintaining them by hand.

<script setup lang="ts">
const head = useLocaleHead({ addDirAttribute: true, addSeoAttributes: true });
useHead({
  htmlAttrs: { lang: head.value.htmlAttrs!.lang, dir: head.value.htmlAttrs!.dir },
  link: [...(head.value.link || [])],
  meta: [...(head.value.meta || [])],
});
</script>

6. Localize the paths themselves where it matters

A German URL reading /de/products is a missed opportunity in a market where the word is Produkte. Custom paths are declared per page and are carried automatically by switchLocalePath.

<script setup lang="ts">
definePageMeta({
  i18n: { paths: { de: '/produkte', ar: '/almuntajat' } },
});
</script>

Configuration reference

Option Type Description / default
strategy 'prefix' | 'prefix_except_default' | 'prefix_and_default' | 'no_prefix' URL shape for every generated route. Default 'prefix_except_default'. Changing it later changes every URL.
lazy boolean Load each locale’s messages on demand rather than bundling all of them. Default false; set it to true for anything beyond two locales.
langDir string Directory holding the message files named in locales[].file. Relative to the project root.
baseUrl string | () => string Absolute origin used for hreflang and canonical links. Required for correct SEO output.
detectBrowserLanguage.redirectOn 'root' | 'all' | 'no prefix' Where detection may redirect. 'root' is the safe default; 'all' invites redirect loops.
detectBrowserLanguage.alwaysRedirect boolean Whether a stored cookie overrides an explicit URL. Default false, which is what you want.
locales[].language string BCP 47 tag used for lang and hreflang. Distinct from code, which is the URL segment.
locales[].dir 'ltr' | 'rtl' Writing direction, applied to the root element by useLocaleHead.

Framework variants

Nuxt with server-side rendering. Detection runs on the server, so the rendered HTML already carries the right locale and no client-side flash occurs. Make sure any caching layer varies on the resolved path rather than on Accept-Language, since the prefix already encodes the decision.

Nuxt static generation. Every localized route is prerendered, which means detection cannot run at request time. The usual arrangement is a static site plus an edge redirect for the bare root path — the pattern described in locale negotiation strategies — because a prerendered page cannot read a header.

Nuxt with a hybrid rendering setup. Route rules can mark localized routes as prerendered and leave a small set server-rendered. The important detail is that the locale prefix is part of the route, so route rules must be written per prefix or with a wildcard that covers all of them.

Vue without Nuxt. vue-i18n gives you the message layer only; route generation, detection and head tags are yours to build on top of the router. That is the work the module is doing, and seeing it listed is a fair way to decide whether the module is worth the dependency.

Verification

# Every locale renders and announces itself correctly
for loc in "" de/ ar/; do
  curl -s "https://example.com/${loc}about" | grep -oE '<html[^>]*>'
done

# Expected
#   <html lang="en-GB" dir="ltr">
#   <html lang="de-DE" dir="ltr">
#   <html lang="ar-EG" dir="rtl">

# The bare root redirects exactly once
curl -sI https://example.com/ | grep -E 'HTTP|location'
#   HTTP/2 302
#   location: /de/

A second, cheaper assertion belongs in the test suite: that switchLocalePath returns a path for every locale on a dynamic route. That is the regression that breaks language switching, and it is invisible until someone tries it on a detail page.

What lazy loading actually saves

lazy: true is described as a performance option, which undersells what it changes structurally. Without it, every locale’s messages are part of the client bundle, so the payload grows linearly with the number of markets and every reader pays for every language. With it, each locale is a separate chunk fetched when that locale is first rendered.

The saving is larger than the raw byte count suggests, for two reasons. Message catalogues compress poorly relative to code — they are natural language, so there is less repetition for the compressor to exploit — and they are parsed as data rather than streamed as script, which on a mid-range phone is a measurable main-thread cost. Ten locales bundled together is routinely a couple of hundred kilobytes of text that ninety percent of readers will never render.

There is one consequence worth planning for. A lazily loaded locale is fetched at navigation time, which means the first render after a language switch waits on a network request. On a fast connection this is invisible; on a slow one it is a blank moment in the interface. Preloading the target locale’s chunk when a switcher opens — a link with rel="prefetch" generated from the same locale list — removes the wait without giving up the bundle saving.

The interaction with namespaces is also worth stating. If your catalogue is namespaced along the lines described in string catalog governance, lazy loading splits by locale and namespacing splits by surface, and the two multiply: a reader on the checkout page in German fetches German checkout copy and nothing else. That combination, rather than either alone, is what keeps a message payload proportional to the page in a product with many markets.

Upgrading from the v7 configuration shape

Projects that started on Nuxt 2 carry a v7 configuration that looks superficially similar and behaves differently in three places, each of which produces a confusing symptom rather than an error.

The vueI18n inline object is the biggest one. In v7, vue-i18n options could be passed inline in the module options. In v8 that key expects a path to a configuration file, so an inline object is either ignored or rejected depending on the exact version — which presents as a fallback locale that does nothing, or as date formats that silently revert to defaults.

locales entries changed shape. The field naming the BCP 47 tag was iso in v7 and is language in v8. An entry still carrying iso produces a lang attribute derived from code instead, so a site configured for de-DE starts announcing de, and any regional hreflang annotation quietly loses its region.

Detection defaults moved. Several detectBrowserLanguage options changed their defaults between major versions, redirectOn among them. A configuration that relied on a default rather than stating it explicitly can change behaviour on upgrade without any line of the config changing — which is the strongest argument for setting detection options explicitly even when the default is what you want.

The upgrade itself is mechanical: move the inline vue-i18n object into i18n.config.ts, rename iso to language in every locale entry, and write out the detection options in full. What is not mechanical is verifying it, and the check that catches all three at once is asserting the rendered <html> attributes and the emitted hreflang links for every locale, since each of the three defects changes one of them.

Common pitfalls

  • redirectOn: 'all'. Detection reconsiders locale on every navigation and eventually contradicts the URL, producing a loop.
  • vue-i18n options in nuxt.config.ts. They are silently ignored. fallbackLocale, datetimeFormats and missingWarn belong in i18n.config.ts.
  • Building switcher URLs by string manipulation. It drops route parameters and cannot resolve custom localized paths, so readers land on the home page.
  • Omitting baseUrl. hreflang links are emitted as relative URLs, which search engines ignore.
  • Using code where language is required. A lang="de" attribute is valid but less precise than de-DE, and a hreflang="de" annotation cannot express a regional variant.
  • Bundling every locale. Without lazy: true each reader downloads every language’s messages, which grows linearly with your market count.

Testing the routing table

Because the module generates routes rather than exposing them, the routing table is the thing most worth asserting — and the thing teams least often test. Three properties cover almost every regression.

The first is existence: every page must have a route in every locale. A page added without thinking about localization will still render at its default path, so a missing localized route goes unnoticed until a reader in that market follows a link. Walking the generated route list and checking that each page’s name appears once per locale catches it at build time.

The second is reciprocity: for any localized route, switchLocalePath must return a valid path for every other locale. This is the property that breaks when a page defines a custom path for two locales out of three, and it fails in a way that ordinary navigation testing misses, because the default locale usually still works.

The third is shape: the emitted paths must match the strategy. After an accidental strategy change — or a page opting out with i18n: false when it should not have — some routes carry a prefix and some do not, and the resulting mix produces exactly the duplicate-content ambiguity that canonical annotations exist to resolve.

None of these needs a browser. The route table is available at build time, which makes the whole suite a fast unit test rather than an end-to-end one, and that is what keeps it running on every pull request rather than nightly.

FAQ

Which routing strategy should a new project pick?

prefix if the markets are roughly equal in importance, because every URL is then explicit and adding a locale changes nothing that already exists. prefix_except_default if there is a clear home market and you want its URLs to stay bare. Avoid no_prefix unless localized URLs genuinely do not matter, and treat prefix_and_default as a migration tool rather than a destination.

Why does my locale switcher send readers to the home page?

Because the URL is being built by replacing a prefix rather than resolved with switchLocalePath. Prefix replacement cannot map /en/products/42 to /de/produkte/42 — it does not know about the custom path or the parameter — so it falls back to the closest route that exists, which is the localized home page.

Do I still need vue-i18n if I use the module?

The module includes it. What you do not need is a separate createI18n call: configure vue-i18n through i18n.config.ts so the module owns the instance, otherwise you end up with two instances and messages that resolve in one but not the other.

How do I add a locale without rebuilding everything?

With lazy: true the messages for a new locale are a new file and a new entry in the locale list, but the routes are still generated at build time, so a build is required. What lazy loading buys is that existing readers do not download the new locale — not that the route table is dynamic.

Can pages opt out of localization?

Yes. definePageMeta({ i18n: false }) leaves a page unprefixed and unlocalized, which is the right treatment for machine-facing routes such as health checks and webhook receivers that should never carry a locale segment.

Part of Framework i18n & Component Routing.