diff --git a/specs/fixtures/issues/3988/app.vue b/specs/fixtures/issues/3988/app.vue
new file mode 100644
index 000000000..fa4ee3efd
--- /dev/null
+++ b/specs/fixtures/issues/3988/app.vue
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/specs/fixtures/issues/3988/i18n/locales/de.json b/specs/fixtures/issues/3988/i18n/locales/de.json
new file mode 100644
index 000000000..3aa350ed3
--- /dev/null
+++ b/specs/fixtures/issues/3988/i18n/locales/de.json
@@ -0,0 +1,3 @@
+{
+ "heading": "Deutsche Ueberschrift"
+}
diff --git a/specs/fixtures/issues/3988/i18n/locales/en.json b/specs/fixtures/issues/3988/i18n/locales/en.json
new file mode 100644
index 000000000..5d76c3f09
--- /dev/null
+++ b/specs/fixtures/issues/3988/i18n/locales/en.json
@@ -0,0 +1,3 @@
+{
+ "heading": "English heading"
+}
diff --git a/specs/fixtures/issues/3988/nuxt.config.ts b/specs/fixtures/issues/3988/nuxt.config.ts
new file mode 100644
index 000000000..d7fa4155c
--- /dev/null
+++ b/specs/fixtures/issues/3988/nuxt.config.ts
@@ -0,0 +1,16 @@
+export default defineNuxtConfig({
+ modules: ['@nuxtjs/i18n'],
+ i18n: {
+ defaultLocale: 'de',
+ strategy: 'prefix_except_default',
+ locales: [
+ { code: 'en', language: 'en-US', file: 'en.json' },
+ { code: 'de', language: 'de-DE', file: 'de.json' }
+ ],
+ detectBrowserLanguage: {
+ useCookie: true,
+ cookieKey: 'i18n_redirected',
+ redirectOn: 'root'
+ }
+ }
+})
diff --git a/specs/fixtures/issues/3988/package.json b/specs/fixtures/issues/3988/package.json
new file mode 100644
index 000000000..b03fba572
--- /dev/null
+++ b/specs/fixtures/issues/3988/package.json
@@ -0,0 +1,13 @@
+{
+ "private": true,
+ "scripts": {
+ "build": "nuxt build",
+ "dev": "nuxt dev",
+ "generate": "nuxt generate",
+ "preview": "nuxt preview"
+ },
+ "devDependencies": {
+ "@nuxtjs/i18n": "latest",
+ "nuxt": "latest"
+ }
+}
diff --git a/specs/fixtures/issues/3988/pages/index.vue b/specs/fixtures/issues/3988/pages/index.vue
new file mode 100644
index 000000000..48867ce5d
--- /dev/null
+++ b/specs/fixtures/issues/3988/pages/index.vue
@@ -0,0 +1,16 @@
+
+
+ {{ $t('heading') }}
+
+
+
+ {{ locale }}
+ Localized home
+ Localized home locale
+
+
+
+
diff --git a/specs/issues/2790.spec.ts b/specs/issues/2790.spec.ts
new file mode 100644
index 000000000..9bbe6f019
--- /dev/null
+++ b/specs/issues/2790.spec.ts
@@ -0,0 +1,122 @@
+import { readFile, stat } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { extname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, test } from 'vitest'
+import { getBrowser, setup, useTestContext } from '../utils'
+
+const contentTypes: Record = {
+ '.css': 'text/css; charset=utf-8',
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.map': 'application/json; charset=utf-8'
+}
+
+async function resolvePublicAsset(publicDir: string, pathname: string) {
+ if (pathname === '/') {
+ return join(publicDir, '200.html')
+ }
+
+ const exactFile = join(publicDir, pathname.slice(1))
+ try {
+ if ((await stat(exactFile)).isFile()) {
+ return exactFile
+ }
+ } catch {}
+
+ const indexFile = join(publicDir, pathname.slice(1), 'index.html')
+ try {
+ if ((await stat(indexFile)).isFile()) {
+ return indexFile
+ }
+ } catch {}
+
+ if (extname(pathname)) {
+ return null
+ }
+
+ return join(publicDir, '200.html')
+}
+
+describe('#2790', async () => {
+ // Reuse the `#3988` fixture so this spec only needs to override the routing strategy to `prefix`.
+ await setup({
+ rootDir: fileURLToPath(new URL(`../fixtures/issues/3988`, import.meta.url)),
+ browser: true,
+ prerender: true,
+ server: false,
+ nuxtConfig: {
+ i18n: {
+ strategy: 'prefix'
+ }
+ }
+ })
+
+ test('redirects the prerender fallback root to the detected locale without mismatches', async () => {
+ const publicDir = useTestContext().nuxt!.options.nitro.output!.publicDir!
+ const server = createServer(async (request, response) => {
+ try {
+ const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
+ const filePath = await resolvePublicAsset(publicDir, pathname)
+ if (filePath == null) {
+ response.statusCode = 404
+ response.end()
+ return
+ }
+
+ const contents = await readFile(filePath)
+
+ response.statusCode = 200
+ response.setHeader('content-type', contentTypes[extname(filePath)] || 'application/octet-stream')
+ response.end(contents)
+ } catch (error) {
+ console.error(error)
+ response.statusCode = 500
+ response.end()
+ }
+ })
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => resolve())
+ })
+
+ try {
+ const address = server.address()
+ if (!address || typeof address === 'string') {
+ throw new Error('Failed to resolve test server address.')
+ }
+
+ const browser = await getBrowser()
+ const page = await browser.newPage({ locale: 'en' })
+ const consoleLogs: { type: string, text: string }[] = []
+ const pageErrors: Error[] = []
+
+ page.on('console', message => consoleLogs.push({ type: message.type(), text: message.text() }))
+ page.on('pageerror', error => pageErrors.push(error))
+
+ await page.goto(`http://127.0.0.1:${address.port}/`)
+ await page.waitForURL(/\/en\/?$/)
+ await page.waitForFunction(() => !window.useNuxtApp?.().isHydrating)
+
+ expect(await page.locator('#translated-heading').innerText()).toEqual('English heading')
+ expect(await page.locator('#translated-heading-v-text').innerText()).toEqual('English heading')
+ expect(await page.locator('#translated-heading-v-html').innerText()).toEqual('English heading')
+ expect(await page.getAttribute('#translated-placeholder', 'placeholder')).toEqual('English heading')
+ expect(await page.locator('#current-locale').innerText()).toEqual('en')
+ expect(await page.getAttribute('#localized-home-link', 'href')).toEqual('/en')
+ expect(await page.getAttribute('#localized-home-link-locale', 'href')).toEqual('/en')
+ expect(pageErrors).toEqual([])
+ expect(
+ consoleLogs.some(log =>
+ ['warning', 'error'].includes(log.type) && /hydration|mismatch/i.test(log.text)
+ )
+ ).toBe(false)
+ } finally {
+ await new Promise((resolve, reject) => {
+ server.close(error => error ? reject(error) : resolve())
+ })
+ }
+ })
+})
diff --git a/specs/issues/3988.spec.ts b/specs/issues/3988.spec.ts
new file mode 100644
index 000000000..b10758723
--- /dev/null
+++ b/specs/issues/3988.spec.ts
@@ -0,0 +1,32 @@
+import { test, expect, describe } from 'vitest'
+import { fileURLToPath } from 'node:url'
+import { setup, url } from '../utils'
+import { renderPage } from '../helper'
+
+describe('#3988', async () => {
+ await setup({
+ rootDir: fileURLToPath(new URL(`../fixtures/issues/3988`, import.meta.url)),
+ browser: true,
+ prerender: true
+ })
+
+ test('avoids hydration mismatch when browser detection redirects from prerendered root', async () => {
+ const { page, consoleLogs, pageErrors } = await renderPage('/', { locale: 'en' })
+
+ await page.waitForURL(url('/en'))
+
+ expect(await page.locator('#translated-heading').innerText()).toEqual('English heading')
+ expect(await page.locator('#translated-heading-v-text').innerText()).toEqual('English heading')
+ expect(await page.locator('#translated-heading-v-html').innerText()).toEqual('English heading')
+ expect(await page.getAttribute('#translated-placeholder', 'placeholder')).toEqual('English heading')
+ expect(await page.locator('#current-locale').innerText()).toEqual('en')
+ expect(await page.getAttribute('#localized-home-link', 'href')).toEqual('/en')
+ expect(await page.getAttribute('#localized-home-link-locale', 'href')).toEqual('/en')
+ expect(pageErrors).toEqual([])
+ expect(
+ consoleLogs.some(log =>
+ ['warning', 'error'].includes(log.type) && /hydration|mismatch/i.test(log.text)
+ )
+ ).toBe(false)
+ })
+})
diff --git a/src/runtime/plugins/route-locale-detect.ts b/src/runtime/plugins/route-locale-detect.ts
index b8cd85ee6..129802e46 100644
--- a/src/runtime/plugins/route-locale-detect.ts
+++ b/src/runtime/plugins/route-locale-detect.ts
@@ -1,5 +1,5 @@
import { useNuxtI18nContext, useResolvedLocale } from '../context'
-import { detectLocale, loadAndSetLocale, navigate } from '../utils'
+import { detectLocale, detectLocaleFromRouteOrDomain, loadAndSetLocale, navigate } from '../utils'
import { addRouteMiddleware, defineNuxtPlugin, defineNuxtRouteMiddleware, useNuxtApp, useRouter } from '#imports'
export default defineNuxtPlugin({
@@ -22,10 +22,16 @@ export default defineNuxtPlugin({
}
const resolvedLocale = useResolvedLocale()
+ const isInitialSsgHydration = __IS_SSG__ && import.meta.client && ctx.initial && __I18N_STRATEGY__ !== 'no_prefix'
+ const initialLocale = isInitialSsgHydration
+ // Keep the prerendered locale stable through hydration and defer browser detection to the SSG plugin.
+ ? detectLocaleFromRouteOrDomain(nuxt, nuxt.$router.currentRoute.value)
+ : (resolvedLocale.value || detectLocale(nuxt, nuxt.$router.currentRoute.value))
await nuxt.runWithContext(() =>
loadAndSetLocale(
nuxt,
- (ctx.initial && resolvedLocale.value) || detectLocale(nuxt, nuxt.$router.currentRoute.value),
+ initialLocale,
+ { syncCookie: !isInitialSsgHydration },
),
)
@@ -35,6 +41,10 @@ export default defineNuxtPlugin({
addRouteMiddleware(
'locale-changing',
defineNuxtRouteMiddleware(async (to) => {
+ if (__IS_SSG__ && import.meta.client && ctx.initial) {
+ return
+ }
+
const locale = await nuxt.runWithContext(() => loadAndSetLocale(nuxt, detectLocale(nuxt, to)))
ctx.initial = false
diff --git a/src/runtime/plugins/ssg-detect.ts b/src/runtime/plugins/ssg-detect.ts
index eef1da034..bcadc57bb 100644
--- a/src/runtime/plugins/ssg-detect.ts
+++ b/src/runtime/plugins/ssg-detect.ts
@@ -1,6 +1,6 @@
import { defineNuxtPlugin, useNuxtApp } from '#imports'
import { useNuxtI18nContext } from '../context'
-import { detectLocale } from '../utils'
+import { detectLocale, loadAndSetLocale, navigate } from '../utils'
export default defineNuxtPlugin({
name: 'i18n:plugin:ssg-detect',
@@ -17,8 +17,12 @@ export default defineNuxtPlugin({
// NOTE: avoid hydration mismatch for SSG mode
nuxt.hook('app:mounted', async () => {
const detected = detectLocale(nuxt, nuxt.$router.currentRoute.value)
- await nuxt.runWithContext(() => nuxt.$i18n.setLocale(detected))
ctx.initial = false
+ const locale = await nuxt.runWithContext(() => loadAndSetLocale(nuxt, detected))
+ // `loadAndSetLocale` can short-circuit when the detected locale already matches the current locale.
+ // Keep the cookie in sync for that first-visit SSG case as well.
+ ctx.setCookieLocale(locale)
+ await nuxt.runWithContext(() => navigate(nuxt, nuxt.$router.currentRoute.value, locale))
})
},
})
diff --git a/src/runtime/server/plugin.ts b/src/runtime/server/plugin.ts
index 42a681f25..e58119fe1 100644
--- a/src/runtime/server/plugin.ts
+++ b/src/runtime/server/plugin.ts
@@ -53,6 +53,127 @@ function createRedirectResponse(event: H3Event, dest: string, code: number) {
}
}
+function serializeInlineScript(value: unknown) {
+ return JSON.stringify(value)
+ .replace(/,
+ detection: ReturnType,
+ defaultLocale: string,
+) {
+ const locales = runtimeI18n.locales.map(locale => ({
+ code: typeof locale === 'string' ? locale : locale.code,
+ language: typeof locale === 'string' ? locale : (locale.language || locale.code),
+ }))
+ const config = {
+ cookieKey: detection.cookieKey,
+ defaultLocale,
+ fallbackLocale: detection.fallbackLocale || '',
+ strategy: __I18N_STRATEGY__,
+ useCookie: detection.useCookie,
+ }
+ const serializedConfig = serializeInlineScript(config)
+ const serializedLocales = serializeInlineScript(locales)
+
+ return `
+`.trim()
+}
+
export default defineNitroPlugin(async (nitro) => {
const runtimeI18n = useRuntimeI18n()
const rootRedirect = resolveRootRedirect(runtimeI18n.rootRedirect)
@@ -191,6 +312,28 @@ export default defineNitroPlugin(async (nitro) => {
nitro.hooks.hook('render:html', (htmlContext, { event }) => {
const ctx = tryUseI18nContext(event)
+ const requestURL = getRequestURL(event)
+ const isStaticRootEntry = requestURL.pathname === '/'
+ || (__I18N_STRATEGY__ === 'prefix' && requestURL.pathname === '/200.html')
+
+ if (
+ __IS_SSG__
+ && detection.enabled
+ && detection.redirectOn === 'root'
+ && __I18N_STRATEGY__ !== 'no_prefix'
+ && !__DIFFERENT_DOMAINS__
+ && !__MULTI_DOMAIN_LOCALES__
+ && !rootRedirect
+ && isStaticRootEntry
+ ) {
+ // Static hosting has no request-time redirect. Bootstrap the locale redirect on the
+ // prerendered root entry before Vue mounts so the initial paint matches the detected locale.
+ // `strategy: 'prefix'` serves the root request from `200.html`, so include that shell too.
+ htmlContext.head.unshift(
+ createStaticRootLocaleRedirectScript(runtimeI18n, detection, ctx?.vueI18nOptions?.defaultLocale || _defaultLocale),
+ )
+ }
+
if (__I18N_PRELOAD__) {
if (ctx == null || Object.keys(ctx.messages ?? {}).length == 0) { return }
diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts
index 67944d871..d8e4adfd0 100644
--- a/src/runtime/utils.ts
+++ b/src/runtime/utils.ts
@@ -220,9 +220,14 @@ declare global {
}
}
-export async function loadAndSetLocale(nuxtApp: NuxtApp, locale: Locale): Promise {
+export async function loadAndSetLocale(
+ nuxtApp: NuxtApp,
+ locale: Locale,
+ options: { syncCookie?: boolean } = {},
+): Promise {
const ctx = useNuxtI18nContext(nuxtApp)
const oldLocale = ctx.getLocale()
+ const syncCookie = options.syncCookie ?? true
// skip if locale is already set and there is no pending locale change to a different locale
if (locale === oldLocale && !ctx.initial && (!ctx.vueI18n.__pendingLocale || ctx.vueI18n.__pendingLocale === locale)) {
@@ -241,7 +246,11 @@ export async function loadAndSetLocale(nuxtApp: NuxtApp, locale: Locale): Promis
}
await ctx.loadMessages(locale)
- await ctx.setLocaleSuspend(locale)
+ if (syncCookie) {
+ await ctx.setLocaleSuspend(locale)
+ } else {
+ await ctx.setLocale(locale)
+ }
return locale
}
@@ -297,6 +306,28 @@ export function detectLocale(nuxtApp: NuxtApp, route: string | CompatRoute): str
return ctx.getLocale() || ctx.getDefaultLocale() || ''
}
+// Route and domain detection are hydration-safe for prerendered pages because they match the rendered HTML.
+export function detectLocaleFromRouteOrDomain(nuxtApp: NuxtApp, route: string | CompatRoute): string {
+ const detectConfig = useI18nDetection(nuxtApp)
+ const detectors = useDetectors(useRequestEvent(nuxtApp), detectConfig, nuxtApp)
+ const ctx = useNuxtI18nContext(nuxtApp)
+ const path = isString(route) ? route : route.path
+
+ if (__DIFFERENT_DOMAINS__ || __MULTI_DOMAIN_LOCALES__) {
+ const hostLocale = detectors.host(path)
+ if (hostLocale && isSupportedLocale(hostLocale)) {
+ return hostLocale
+ }
+ }
+
+ const routeLocale = detectors.route(route)
+ if (routeLocale && isSupportedLocale(routeLocale)) {
+ return routeLocale
+ }
+
+ return ctx.getLocale() || ctx.getDefaultLocale() || ''
+}
+
export function navigate(nuxtApp: NuxtApp, to: CompatRoute, locale: string) {
if (!__I18N_ROUTING__ || __DIFFERENT_DOMAINS__) { return }