Next.js official guide recommends wrapping routes with a dynamic [lang]
segment. Although it works it comes with the drawback of a poor SEO score (see the previous post for more details).
TL;DR
Use next-roots library for generating translated routes and creating page links in your app.
Prerequisites
This guide requires Next.js 13 project to be installed with app dir support enabled. Follow the official installation guide if needed.
Objectives
Build an SEO-friendly internationalized blog site that meets the following acceptance criteria:
- AC1: The site offers English, Spain, and Czech localization.
- AC2: The site meets demanded URL structure (see below).
- AC3: The login and signup share the same layout.
Demanded URL structure:
- Home page:
/en
,/es
,/cs
- Article detail:
/en/[id]
,/es/[id]
,/cs/[id]
- Login page:
/en/login
,/es/acceso
,/cs/prihlaseni
- Signup page:
/en/signup
,/es/registrar
,/cs/registrace
Installing next-roots package
To be able to generate translated routes we need to install additional package called next-roots.
yarn add next-roots
It is also required to add ESBuild into devDependencies to be able to compile i18n configuration files for next-roots
yarn add --dev exbuild
Setting up the origin
To be able to generate localised routes the original (default locale) structure must be set as first. Let's use English as the default locale in our case and create the following routes manually under the folder called roots
.
├── roots
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── login
│ │ │ └── page.ts
│ │ └── signup
│ │ └── page.ts
│ ├── [id]
│ │ └── page.ts
│ └── page.js
└── ...
Note that the structure is the same as you would use for Next.js APP folder. Instead of roots
folder you can use anything you want and specify that name in roots.config.js
file as originDir
param.
Setting up roots.config.js
file
Simple configuration file called roots.config.js
is required to be placed in your project root to tell next-roots where to look for the origin files, which locales are we going to generate and where to save localised files.
// roots.config.js
const path = require('path')
module.exports = {
// where original routes are placed
originDir: path.resolve(__dirname, 'roots'),
// where translated routes will be saved
localizedDir: path.resolve(__dirname, 'app'),
// which locales are we going to use (URL prefixes)
locales: ['en', 'es', 'cs'],
// which locale is considered as default when no other match
defaultLocale: 'en',
// serves default locale on "/en" instead of "/"
prefixDefaultLocale: true,
}
Setting up URL translations
For every route segment that needs to be translated the i18n file must be created right next to its page file.
├── roots
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── login
│ │ │ ├── i18n.js
│ │ │ └── page.ts
│ │ └── signup
│ │ ├── i18n.js
│ │ └── page.ts
│ ├── [id]
│ │ └── page.ts
│ └── page.js
└── ...
Note that i18n
file can be written in JS or TS. That is because every i18n file is compiled using ESBuild under the hood.
Let's define our translations for the login
segment:
// in roots/(auth)/login/i18n.js
module.exports.routeNames = [
{ locale: 'es', path: 'acesso' },
{ locale: 'cs', path: 'prihlaseni' },
// you don't need to specify "en" translation as long as it matches the route folder name
]
If you need to fetch the translation from async storage you can use async function:
// in roots/(auth)/login/i18n.js
export async function generateRouteNames() {
// "getTranslation" is custom async function that loads translated paths from DB
const { esPath, csPath } = await getTranslations('/login')
return [
{ locale: 'es', path: esPath },
{ locale: 'cs', path: csPath },
]
}
The same must be done for signup
segment:
// in roots/(auth)/signup/i18n.js
module.exports.routeNames = [
{ locale: 'es', path: 'registrar' },
{ locale: 'cs', path: 'registrace' },
]
Generating translated routes
When you are done with configuration and i18n files it is time to run the generator.
yarn next-roots
That will create the APP folder shaped like this:
├── app
│ ├── en
│ │ ├── (auth)
│ │ │ ├── layout.tsx
│ │ │ ├── login
│ │ │ │ └── page.ts
│ │ │ └── signup
│ │ │ └── page.ts
│ │ ├── [id]
│ │ │ └── page.ts
│ │ └── page.js
│ ├── es
│ │ ├── (auth)
│ │ │ ├── layout.tsx
│ │ │ ├── registrar
│ │ │ │ └── page.ts
│ │ │ └── acceso
│ │ │ ├── i18n.js
│ │ │ └── page.ts
│ │ ├── [id]
│ │ │ └── page.ts
│ │ └── page.js
│ ├── cs
│ │ ├── (auth)
│ │ │ ├── layout.tsx
│ │ │ ├── prihlaseni
│ │ │ │ └── page.ts
│ │ │ └── registrace
│ │ │ └── page.ts
│ │ ├── [id]
│ │ │ └── page.ts
│ │ └── page.js
├── roots
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── login
│ │ │ ├── i18n.js
│ │ │ └── page.ts
│ │ └── signup
│ │ ├── i18n.js
│ │ └── page.ts
│ ├── [id]
│ │ └── page.ts
│ └── page.js
└── ...
Note that i18n
files are not copied into APP folder. Right now when you run yarn next dev
you will be able to access your pages on different locales without any rewrites or dynamic [lang]
segment. All required routes are now statically placed in your APP folder.
Components like pages, layouts or template that were generated in app folder just re-exports what was exported from corresponding components in roots
folder. That means your roots
are still bundled into your application.
Bare in mind that once you need to change the translation of your need to run next-roots
again.
Read more in next-roots readme and its example.
BONUS: Creating links
NextRoots comes with its own strongly typed Router using which you can easily create localised links. It warns you when you forget about passing down the dynamic parameters.
import { Router, schema, RouteName } from 'next-roots'
const router = new Router(schema)
// for getting '/cs/prihlaseni'
router.getHref('/login', { locale: 'cs' })
// for getting '/es/acesso'
router.getHref('/login', { locale: 'es' })
// typescript will yield at you here as /invalid is not a valid route
router.getHref('/invalid', { locale: 'cs' })
const routeNameValid: RouteName = '/login'
const routeNameInvalid: RouteName = '/invalid' // yields TS error
// for getting '/cs/1'
router.getHref('/[id]', { locale: 'cs', articleId: '1' })
// for getting '/es/1'
router.getHref('/[id]', { locale: 'es', articleId: '1' })
// typescript will yield at you here because of the missing required parameter called “id”
router.getHref('/[id]', { locale: 'cs' })
const routeDynamic: RouteName = '/[id]'
const paramsDynamicValid: RouteParamsDynamic<typeof routeDynamic> = {
locale: 'cs',
id: '1',
}
// typescript will yield at you here because of the missing required parameter called “id”
const paramsDynamicInvalid: RouteParamsDynamic<typeof routeDynamic> = {
locale: 'cs',
}
Note that thanks to typed router your IDE autocompletion will help you to fill the route name in router.getHref()
.
Conclusion
WHile [lang]
approach works well in the most cases when it comes to SEO and easy links the next-roots package can be pretty handy.
Top comments (1)
Thanks for your hard work and making this available to the public!