diff --git a/docs/content/en/browser-language-detection.md b/docs/content/en/browser-language-detection.md
index 50d000af5..b21d26a29 100644
--- a/docs/content/en/browser-language-detection.md
+++ b/docs/content/en/browser-language-detection.md
@@ -13,17 +13,20 @@ By default, **nuxt-i18n** attempts to redirect users to their preferred language
// ...
detectBrowserLanguage: {
useCookie: true,
- cookieKey: 'i18n_redirected'
+ cookieKey: 'i18n_redirected',
+ // onlyOnRoot: true,
}
}]
```
-Browser language is detected either from `navigator` when running on client side, or from the `accept-language` HTTP header. Configured `locales` (or locales `code`s when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match, the language code (letters before `-`) are matched against configured locales (for backwards compatibility).
+For better SEO, it's recommended to set `onlyOnRoot` to `true`. With it set, the language detection is only attempted when the user visits the root path (`/`) of the site. This allows crawlers to access the requested page rather than being redirected away based on detected locale. It also allows linking to pages in specific locales.
+Browser language is detected either from `navigator` when running on client-side, or from the `accept-language` HTTP header. Configured `locales` (or locales `code`s when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match for the full locale, the language code (letters before `-`) are matched against configured locales (for backward-compatibility).
+
To prevent redirecting users every time they visit the app, **nuxt-i18n** sets a cookie after the first redirection. You can change the cookie's name by setting `detectBrowserLanguage.cookieKey` option to whatever you'd like, the default is _i18n_redirected_.
```js{}[nuxt.config.js]
diff --git a/docs/content/en/options-reference.md b/docs/content/en/options-reference.md
index 63c9a9fd4..54be7247b 100644
--- a/docs/content/en/options-reference.md
+++ b/docs/content/en/options-reference.md
@@ -62,7 +62,7 @@ Here are all the options available when configuring the module and their default
// - 'prefix_and_default': add locale prefix for every locale and default
strategy: 'prefix_except_default',
- // Wether or not the translations should be lazy-loaded, if this is enabled,
+ // Whether or not the translations should be lazy-loaded, if this is enabled,
// you MUST configure langDir option, and locales must be an array of objects,
// each containing a file key
lazy: false,
@@ -79,9 +79,10 @@ Here are all the options available when configuring the module and their default
// }
rootRedirect: null,
- // Enable browser language detection to automatically redirect user
- // to their preferred language as they visit your app for the first time
- // Set to false to disable
+ // Enable browser language detection to automatically redirect visitors to their
+ // preferred language as they visit your site for the first time.
+ // Note that for better SEO it's recommended to set 'onlyOnRoot' to true.
+ // Set to false to disable.
detectBrowserLanguage: {
// If enabled, a cookie is set once a user has been redirected to his
// preferred language to prevent subsequent redirections
@@ -99,7 +100,10 @@ Here are all the options available when configuring the module and their default
// Set to always redirect to value stored in the cookie, not just once
alwaysRedirect: false,
// If no locale for the browsers locale is a match, use this one as a fallback
- fallbackLocale: null
+ fallbackLocale: null,
+ // Set to true (recommended for improved SEO) to only attempt to detect browser locale
+ // on the root path ("/") of the site. Only effective when using strategy other than 'no_prefix'.
+ onlyOnRoot: false,
},
// Set this to true if you're using different domains for each language
diff --git a/docs/content/es/browser-language-detection.md b/docs/content/es/browser-language-detection.md
index 7af7b41f5..df5a5681e 100644
--- a/docs/content/es/browser-language-detection.md
+++ b/docs/content/es/browser-language-detection.md
@@ -11,13 +11,23 @@ Por defecto, **nuxt-i18n** intenta redirigir a los usuarios a su idioma preferid
```js{}[nuxt.config.js]
['nuxt-i18n', {
+ // ...
detectBrowserLanguage: {
useCookie: true,
- cookieKey: 'i18n_redirected'
+ cookieKey: 'i18n_redirected',
+ // onlyOnRoot: true,
}
}]
```
+
+
+For better SEO, it's recommended to set `onlyOnRoot` to `true`. With it set, the language detection is only attempted when the user visits the root path (`/`) of the site. This allows crawlers to access the requested page rather than being redirected away based on detected locale. It also allows linking to pages in specific locales.
+
+
+
+Browser language is detected either from `navigator` when running on client-side, or from the `accept-language` HTTP header. Configured `locales` (or locales `code`s when locales are specified in object form) are matched against locales reported by the browser (for example `en-US,en;q=0.9,no;q=0.8`). If there is no exact match for the full locale, the language code (letters before `-`) are matched against configured locales (for backward-compatibility).
+
Para evitar redirigir a los usuarios cada vez que visitan la aplicación, **nuxt-i18n** establece una cookie después de la primera redirección. Puede cambiar el nombre de la cookie configurando la opción `detectBrowserLanguage.cookieKey` a lo que desee, el valor predeterminado es _i18n_redirected_.
```js{}[nuxt.config.js]
diff --git a/docs/content/es/options-reference.md b/docs/content/es/options-reference.md
index e362f4813..d5677e256 100644
--- a/docs/content/es/options-reference.md
+++ b/docs/content/es/options-reference.md
@@ -10,6 +10,19 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor
```js
{
// vue-i18n configuration
+ // See documentation: http://kazupon.github.io/vue-i18n/api/#constructor-options
+ // To be able to pass more complex configuration options that can't be stringified, it's also
+ // supported to set this property to a path to a local configuration file. File needs to export
+ // a function (that will be passed a Nuxt context as a parameter) or plain object.
+ // Example path: '~/plugins/vue-i18n.js'
+ // Example file content:
+ // export default context => {
+ // return {
+ // modifiers: {
+ // snakeCase: (str) => str.split(' ').join('-')
+ // }
+ // }
+ // }
vueI18n: {},
// If true, vue-i18n-loader is added to Nuxt's Webpack config
@@ -49,7 +62,7 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor
// - 'prefix_and_default': add locale prefix for every locale and default
strategy: 'prefix_except_default',
- // Wether or not the translations should be lazy-loaded, if this is enabled,
+ // Whether or not the translations should be lazy-loaded, if this is enabled,
// you MUST configure langDir option, and locales must be an array of objects,
// each containing a file key
lazy: false,
@@ -66,9 +79,10 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor
// }
rootRedirect: null,
- // Enable browser language detection to automatically redirect user
- // to their preferred language as they visit your app for the first time
- // Set to false to disable
+ // Enable browser language detection to automatically redirect visitors to their
+ // preferred language as they visit your site for the first time.
+ // Note that for better SEO it's recommended to set 'onlyOnRoot' to true.
+ // Set to false to disable.
detectBrowserLanguage: {
// If enabled, a cookie is set once a user has been redirected to his
// preferred language to prevent subsequent redirections
@@ -86,7 +100,10 @@ Aquí están todas las opciones disponibles al configurar el módulo y sus valor
// Set to always redirect to value stored in the cookie, not just once
alwaysRedirect: false,
// If no locale for the browsers locale is a match, use this one as a fallback
- fallbackLocale: null
+ fallbackLocale: null,
+ // Set to true (recommended for improved SEO) to only attempt to detect browser locale
+ // on the root path ("/") of the site. Only effective when using strategy other than 'no_prefix'.
+ onlyOnRoot: false,
},
// Set this to true if you're using different domains for each language
diff --git a/src/helpers/constants.js b/src/helpers/constants.js
index 8f3193738..9a2f327de 100644
--- a/src/helpers/constants.js
+++ b/src/helpers/constants.js
@@ -37,7 +37,8 @@ exports.DEFAULT_OPTIONS = {
cookieKey: 'i18n_redirected',
cookieSecure: false,
alwaysRedirect: false,
- fallbackLocale: ''
+ fallbackLocale: '',
+ onlyOnRoot: false
},
differentDomains: false,
seo: false,
diff --git a/src/templates/plugin.main.js b/src/templates/plugin.main.js
index 11424689f..d21009eca 100644
--- a/src/templates/plugin.main.js
+++ b/src/templates/plugin.main.js
@@ -38,6 +38,7 @@ import {
Vue.use(VueI18n)
+const { alwaysRedirect, onlyOnRoot, fallbackLocale } = detectBrowserLanguage
const getLocaleFromRoute = createLocaleFromRouteGetter(localeCodes, { routesNameSeparator, defaultLocaleRouteNameSuffix })
/** @type {import('@nuxt/types').Plugin} */
@@ -79,10 +80,10 @@ export default async (context) => {
if (!initialSetup) {
app.i18n.beforeLanguageSwitch(oldLocale, newLocale)
+ }
- if (useCookie) {
- app.i18n.setLocaleCookie(newLocale)
- }
+ if (useCookie) {
+ app.i18n.setLocaleCookie(newLocale)
}
// Lazy-loading enabled
@@ -124,7 +125,11 @@ export default async (context) => {
}
if (getLocaleFromRoute(route) === locale) {
- return ''
+ // If "onlyOnRoot" is set and strategy is "prefix_and_default", prefer unprefixed route for
+ // default locale.
+ if (!onlyOnRoot || locale !== defaultLocale || strategy !== STRATEGIES.PREFIX_AND_DEFAULT) {
+ return ''
+ }
}
// At this point we are left with route that either has no or different locale.
@@ -166,7 +171,7 @@ export default async (context) => {
app.i18n.__baseUrl = resolveBaseUrl(baseUrl, context)
const finalLocale =
- (detectBrowserLanguage && doDetectBrowserLanguage()) ||
+ (detectBrowserLanguage && doDetectBrowserLanguage(route)) ||
getLocaleFromRoute(route) || app.i18n.locale || app.i18n.defaultLocale || ''
await app.i18n.setLocale(finalLocale)
@@ -174,13 +179,15 @@ export default async (context) => {
return [null, null]
}
- const doDetectBrowserLanguage = () => {
+ const doDetectBrowserLanguage = route => {
// Browser detection is ignored if it is a nuxt generate.
if (process.static && process.server) {
return false
}
- const { alwaysRedirect, fallbackLocale } = detectBrowserLanguage
+ if (onlyOnRoot && strategy !== STRATEGIES.NO_PREFIX && route.path !== '/') {
+ return false
+ }
let matchedLocale
@@ -199,8 +206,6 @@ export default async (context) => {
if (finalLocale && (!useCookie || alwaysRedirect || !app.i18n.getLocaleCookie())) {
if (finalLocale !== app.i18n.locale) {
return finalLocale
- } else if (useCookie && !app.i18n.getLocaleCookie()) {
- app.i18n.setLocaleCookie(finalLocale)
}
}
@@ -244,7 +249,7 @@ export default async (context) => {
}
}
- let finalLocale = detectBrowserLanguage && doDetectBrowserLanguage()
+ let finalLocale = detectBrowserLanguage && doDetectBrowserLanguage(route)
if (!finalLocale) {
if (vuex && vuex.syncLocale && store && store.state[vuex.moduleName].locale !== '') {
diff --git a/src/templates/plugin.routing.js b/src/templates/plugin.routing.js
index 6a604c52b..6ea19df3b 100644
--- a/src/templates/plugin.routing.js
+++ b/src/templates/plugin.routing.js
@@ -148,11 +148,10 @@ function getRouteBaseName (givenRoute) {
}
function getLocaleRouteName (routeName, locale) {
- const name = routeName + (strategy === STRATEGIES.NO_PREFIX ? '' : routesNameSeparator + locale)
+ let name = routeName + (strategy === STRATEGIES.NO_PREFIX ? '' : routesNameSeparator + locale)
- // Match route without prefix for default locale
if (locale === defaultLocale && strategy === STRATEGIES.PREFIX_AND_DEFAULT) {
- return name + routesNameSeparator + defaultLocaleRouteNameSuffix
+ name += routesNameSeparator + defaultLocaleRouteNameSuffix
}
return name
diff --git a/test/browser.test.js b/test/browser.test.js
index 51b92e9c9..7a8005cb7 100644
--- a/test/browser.test.js
+++ b/test/browser.test.js
@@ -712,7 +712,52 @@ describe(`${browserString} (SPA with router in hash mode)`, () => {
})
})
-describe(`${browserString} (alwaysRedirect)`, () => {
+describe(`${browserString} (onlyOnRoot + alwaysRedirect + no_prefix)`, () => {
+ /** @type {Nuxt} */
+ let nuxt
+ /** @type {import('playwright-chromium').ChromiumBrowser} */
+ let browser
+
+ beforeAll(async () => {
+ const overrides = {
+ i18n: {
+ defaultLocale: 'en',
+ strategy: 'no_prefix',
+ detectBrowserLanguage: {
+ useCookie: false,
+ alwaysRedirect: true,
+ onlyOnRoot: true
+ }
+ }
+ }
+
+ nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt
+ browser = await createBrowser()
+ })
+
+ afterAll(async () => {
+ if (browser) {
+ await browser.close()
+ }
+ await nuxt.close()
+ })
+
+ test('onlyOnRoot does not affect locale detection on root path', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: fr')
+ })
+
+ test('onlyOnRoot does not affect locale detection on sub-path', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/about'))
+ expect(await (await page.$('#current-page'))?.textContent()).toContain('page: À propos')
+ // Custom paths are not supported in "no_prefix" strategy.
+ // expect(await getRouteFullPath(page)).toBe('/a-propos')
+ })
+})
+
+describe(`${browserString} (alwaysRedirect, prefix)`, () => {
/** @type {Nuxt} */
let nuxt
/** @type {import('playwright-chromium').ChromiumBrowser} */
@@ -750,6 +795,208 @@ describe(`${browserString} (alwaysRedirect)`, () => {
})
})
+describe(`${browserString} (onlyOnRoot + prefix_except_default)`, () => {
+ /** @type {Nuxt} */
+ let nuxt
+ /** @type {import('playwright-chromium').ChromiumBrowser} */
+ let browser
+
+ beforeAll(async () => {
+ const overrides = {
+ i18n: {
+ defaultLocale: 'en',
+ strategy: 'prefix_except_default',
+ detectBrowserLanguage: {
+ onlyOnRoot: true
+ }
+ }
+ }
+
+ nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt
+ browser = await createBrowser()
+ })
+
+ afterAll(async () => {
+ if (browser) {
+ await browser.close()
+ }
+ await nuxt.close()
+ })
+
+ test('redirects to detected locale on unprefixed root path', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: fr')
+ })
+
+ test('does not detect locale and redirect on unprefixed non-root path', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/simple'))
+ expect(await (await page.$('#container'))?.textContent()).toContain('Homepage')
+ expect(await getRouteFullPath(page)).toBe('/simple')
+ })
+
+ test('does not detect locale and redirect on prefixed, root path', async () => {
+ const page = await browser.newPage({ locale: 'en' })
+ await page.goto(url('/fr/'))
+ expect(await (await page.$('#current-page'))?.textContent()).toContain('page: Accueil')
+ expect(await getRouteFullPath(page)).toBe('/fr/')
+ })
+
+ test('does not detect locale and redirect on prefixed, non-root path', async () => {
+ const page = await browser.newPage({ locale: 'en' })
+ await page.goto(url('/fr/a-propos'))
+ expect(await (await page.$('#current-page'))?.textContent()).toContain('page: À propos')
+ expect(await getRouteFullPath(page)).toBe('/fr/a-propos')
+ })
+
+ test('does not redirect to locale stored in cookie on second navigation to root path', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: fr')
+ expect(await getRouteFullPath(page)).toBe('/fr')
+ const browserContext = page.context()
+ // Verify that cookie was set.
+ const cookies = await browserContext.cookies()
+ expect(cookies).toMatchObject([{ name: 'i18n_redirected', value: 'fr' }])
+ // Navigate again to root, expecting to NOT be redirected again.
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: en')
+ expect(await getRouteFullPath(page)).toBe('/')
+ })
+})
+
+describe(`${browserString} (onlyOnRoot + alwaysRedirect + prefix_except_default)`, () => {
+ /** @type {Nuxt} */
+ let nuxt
+ /** @type {import('playwright-chromium').ChromiumBrowser} */
+ let browser
+
+ beforeAll(async () => {
+ const overrides = {
+ i18n: {
+ defaultLocale: 'en',
+ strategy: 'prefix_except_default',
+ detectBrowserLanguage: {
+ alwaysRedirect: true,
+ onlyOnRoot: true
+ }
+ }
+ }
+
+ nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt
+ browser = await createBrowser()
+ })
+
+ afterAll(async () => {
+ if (browser) {
+ await browser.close()
+ }
+ await nuxt.close()
+ })
+
+ test('redirects to locale stored in cookie on second navigation to root path', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: fr')
+ expect(await getRouteFullPath(page)).toBe('/fr')
+ const browserContext = page.context()
+ // Verify that cookie was set.
+ const cookies = await browserContext.cookies()
+ expect(cookies).toMatchObject([{ name: 'i18n_redirected', value: 'fr' }])
+ // Navigate again to root, expecting to be redirected again.
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: fr')
+ expect(await getRouteFullPath(page)).toBe('/fr')
+ })
+})
+
+describe(`${browserString} (onlyOnRoot + prefix_and_default)`, () => {
+ /** @type {Nuxt} */
+ let nuxt
+ /** @type {import('playwright-chromium').ChromiumBrowser} */
+ let browser
+
+ beforeAll(async () => {
+ const overrides = {
+ i18n: {
+ defaultLocale: 'en',
+ strategy: 'prefix_and_default',
+ detectBrowserLanguage: {
+ onlyOnRoot: true
+ }
+ }
+ }
+
+ nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt
+ browser = await createBrowser()
+ })
+
+ afterAll(async () => {
+ if (browser) {
+ await browser.close()
+ }
+ await nuxt.close()
+ })
+
+ test('redirects from prefixed to unprefixed default locale', async () => {
+ const page = await browser.newPage()
+ await page.goto(url('/en'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: en')
+ expect(await getRouteFullPath(page)).toBe('/')
+ })
+
+ test('does not redirect from unprefixed default locale', async () => {
+ const page = await browser.newPage()
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: en')
+ expect(await getRouteFullPath(page)).toBe('/')
+ })
+})
+
+describe(`${browserString} (onlyOnRoot + prefix)`, () => {
+ /** @type {Nuxt} */
+ let nuxt
+ /** @type {import('playwright-chromium').ChromiumBrowser} */
+ let browser
+
+ beforeAll(async () => {
+ const overrides = {
+ i18n: {
+ defaultLocale: 'en',
+ strategy: 'prefix',
+ detectBrowserLanguage: {
+ onlyOnRoot: true
+ }
+ }
+ }
+
+ nuxt = (await setup(loadConfig(__dirname, 'basic', overrides, { merge: true }))).nuxt
+ browser = await createBrowser()
+ })
+
+ afterAll(async () => {
+ if (browser) {
+ await browser.close()
+ }
+ await nuxt.close()
+ })
+
+ test('does not redirect from one locale to another', async () => {
+ const page = await browser.newPage({ locale: 'fr' })
+ await page.goto(url('/en'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: en')
+ expect(await getRouteFullPath(page)).toBe('/en')
+ })
+
+ test('redirects from root (404) path to default locale', async () => {
+ const page = await browser.newPage()
+ await page.goto(url('/'))
+ expect(await (await page.$('body'))?.textContent()).toContain('locale: en')
+ expect(await getRouteFullPath(page)).toBe('/en')
+ })
+})
+
describe(`${browserString} (vuex disabled)`, () => {
/** @type {Nuxt} */
let nuxt
diff --git a/test/module.test.js b/test/module.test.js
index dc3fb324f..d70bdf3b0 100644
--- a/test/module.test.js
+++ b/test/module.test.js
@@ -1135,6 +1135,34 @@ describe('with router base', () => {
const newRoute = window.$nuxt.localePath('about')
expect(newRoute).toBe('/about-us')
})
+
+ test('detectBrowserLanguage redirects on base path', async () => {
+ const requestOptions = {
+ followRedirect: false,
+ resolveWithFullResponse: true,
+ simple: false, // Don't reject on non-2xx response
+ headers: {
+ 'Accept-Language': 'fr'
+ }
+ }
+ const response = await get('/app/', requestOptions)
+ expect(response.statusCode).toBe(302)
+ expect(response.headers.location).toBe('/app/fr')
+ })
+
+ test('detectBrowserLanguage redirects on non-base path', async () => {
+ const requestOptions = {
+ followRedirect: false,
+ resolveWithFullResponse: true,
+ simple: false, // Don't reject on non-2xx response
+ headers: {
+ 'Accept-Language': 'fr'
+ }
+ }
+ const response = await get('/app/simple', requestOptions)
+ expect(response.statusCode).toBe(302)
+ expect(response.headers.location).toBe('/app/fr/simple')
+ })
})
describe('baseUrl', () => {
@@ -1433,6 +1461,17 @@ describe('no_prefix + detectBrowserLanguage + alwaysRedirect', () => {
const dom = getDom(html)
expect(dom.querySelector('#current-locale')?.textContent).toBe('locale: en')
})
+
+ test('applies detected locale on non-root path', async () => {
+ const requestOptions = {
+ headers: {
+ 'Accept-Language': 'fr'
+ }
+ }
+ const html = await get('/about', requestOptions)
+ const dom = getDom(html)
+ expect(dom.querySelector('#current-page')?.textContent).toBe('page: À propos')
+ })
})
describe('prefix + detectBrowserLanguage + alwaysRedirect', () => {
diff --git a/types/nuxt-i18n.d.ts b/types/nuxt-i18n.d.ts
index a2fcca68b..73e74f397 100644
--- a/types/nuxt-i18n.d.ts
+++ b/types/nuxt-i18n.d.ts
@@ -34,6 +34,7 @@ declare namespace NuxtVueI18n {
cookieKey?: string
alwaysRedirect?: boolean
fallbackLocale?: Locale | null
+ onlyOnRoot?: boolean
}
interface RootRedirectInterface {