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.
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.
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.
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>
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,datetimeFormatsandmissingWarnbelong ini18n.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.hreflanglinks are emitted as relative URLs, which search engines ignore. - Using
codewherelanguageis required. Alang="de"attribute is valid but less precise thande-DE, and ahreflang="de"annotation cannot express a regional variant. - Bundling every locale. Without
lazy: trueeach 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.
Related
- Nuxt i18n redirect loop with prefix_except_default — confining detection to the one URL that has not already answered the question.
- Nuxt i18n lazy messages missing after a locale switch — why a string captured at setup time is stale by construction.
- Nuxt i18n vue-i18n options ignored — the runtime options that do nothing at all in the module config.
- Vue i18n Composition API Guide — the message layer the module configures.
- Locale-Aware SEO & hreflang — what the generated head tags have to satisfy.
- Next.js i18n Routing Setup — the same problems solved in the other major meta-framework.
- Locale Negotiation Strategies — the detection rules the module implements.
- Fallback Chain Configuration — what
fallbackLocalein the runtime config is doing.
Part of Framework i18n & Component Routing.