---
createdAt: 2026-09-02
updatedAt: 2026-09-02
title: Nuxt i18n - routing strategies and the SSR payload cost
description: How to configure @nuxtjs/i18n routing strategies for SEO, why SSR makes you ship every translation twice, and what lazy loading with langDir really does.
keywords:
- nuxt i18n
- "@nuxtjs/i18n"
- Nuxt internationalization
- useSwitchLocalePath
- useLocaleHead
- prefix_except_default
- Nuxt SSR payload
- Intlayer
slugs:
- blog
- i18n-technologies
- frameworks
- nuxt
author: aymericzip
---
# Nuxt i18n: routing strategies and the SSR payload cost
`@nuxtjs/i18n` (usually written `@nuxt/i18n`) is the default answer for a multilingual Nuxt app, and it is a good one: generated locale routes, SEO head tags, per-page path overrides, lazy catalogs. What the docs do not spell out is that server rendering makes you pay for your translations twice, once in the HTML and once in the hydration payload. This post covers the routing strategies worth picking, that payload cost, and how to measure it on your own site.
## Table of Contents
## What the module actually does
`@nuxtjs/i18n` wraps `vue-i18n` and adds the Nuxt parts on top: it rewrites your `pages/` tree into locale-prefixed routes, injects a locale-detection middleware, manages a locale cookie, and gives you composables that know about the current prefix. If you are unfamiliar with the underlying library, [how vue-i18n works and where it hurts](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/list_i18n_technologies/frameworks/vue.md) covers the message format, plurals and the compiler build.
The minimal config looks like this:
```ts fileName="nuxt.config.ts"
export default defineNuxtConfig({
modules: ["@nuxtjs/i18n"],
i18n: {
strategy: "prefix_except_default",
defaultLocale: "en",
locales: [
{ code: "en", language: "en-US", file: "en.json" },
{ code: "fr", language: "fr-FR", file: "fr.json" },
],
langDir: "locales/",
},
});
```
Everything else in the module is a consequence of two choices: the `strategy`, and whether your catalogs are lazy.
## Pick the routing strategy first
The `strategy` option decides your URL shape, and changing it later means redirects for every indexed page. There are four values.
| Strategy | Default locale URL | Other locale URL | Indexable per locale |
| :---------------------- | :----------------------- | :--------------- | :------------------------------------ |
| `prefix_except_default` | `/about` | `/fr/about` | Yes |
| `prefix` | `/en/about` | `/fr/about` | Yes |
| `prefix_and_default` | `/about` and `/en/about` | `/fr/about` | Yes, with a duplicate to canonicalize |
| `no_prefix` | `/about` | `/about` | No |
`no_prefix` keeps the locale in a cookie and serves every language from the same URL. A crawler has one URL to index and no way to request the other languages, so only one language ends up in the index. There is no `hreflang` setup that fixes this, because `hreflang` needs distinct URLs to point at. Use `no_prefix` only for an authenticated app that search engines never see.
`prefix_and_default` is the trap in the list. It exposes both `/about` and `/en/about` for the same content, so you inherit a duplicate-URL problem and have to make sure the canonical tag agrees with your sitemap. `prefix_except_default` is the sane default; `prefix` is better if you want all locales symmetrical and do not mind a redirect from `/`.
## The SSR payload: you ship the catalog twice
This is the Nuxt-specific issue, and it is invisible until you look at the response body.
With SSR, the server loads the messages for the requested locale, renders the HTML, then serializes the state it used into the page so the client can hydrate without refetching. Translations are part of that state. The French string that already appears as visible text in your markup appears again, verbatim, inside the serialized payload.
That is tolerable when the payload holds only what the page renders. It is not, because a `vue-i18n` locale catalog is one object: there is no per-route split. Load `fr.json` to render twelve strings on `/fr/contact` and the payload carries the whole French catalog, including your checkout copy, your legal pages and your error messages. The Intlayer [Vue i18n benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/vue.md) states this directly: on Nuxt, `@nuxt/i18n` ends up including the messages from all pages into a single one, and past roughly ten pages it becomes a real problem.
Check your own site in one command. Take a string that only exists on a different page and count it in the response:
```bash
curl -s https://example.com/fr/contact | grep -c "Ajouter au panier"
```
Zero is what you want. One means the catalog is in the payload. Two or more means it is in the payload and in an inlined chunk. `nuxt generate` behaves the same way, with the payload baked into the emitted `_payload.json` files, so prerendering does not save you.
The library itself is a smaller line item: `vue-i18n@11.4.0` measures about **24.3 kB** after bundling and minification in that benchmark. Content is the bigger number in almost every real app.
## Lazy loading splits by locale, not by page
`langDir` plus a `file` per locale gives you real code splitting across languages. A visitor on `/fr` does not download `en.json`, and adding a tenth locale costs nothing to the other nine.
```ts fileName="nuxt.config.ts"
i18n: {
lazy: true,
langDir: "locales/",
locales: [
{ code: "en", language: "en-US", file: "en.json" },
{ code: "fr", language: "fr-FR", file: "fr.json" },
],
}
```
What it does not do is split by route. `fr.json` is one file and one runtime object; loading it loads every page's copy. You can hand-roll namespaces by giving each locale several files and calling `setLocaleMessage` yourself, but you are then maintaining the mapping from routes to namespaces by hand, and nothing tells you when a namespace stops being used. This is the same limitation described in the [per-component vs centralized i18n comparison](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/per-component_vs_centralized_i18n.md).
## Links, not buttons
`useLocalePath()` localizes an internal path in the current locale. `useSwitchLocalePath()` returns the current route in another locale, which is what a language switcher needs.
```vue fileName="components/LocaleSwitcher.vue"
```
Render this as links, never as a `