Step-by-step guide for SEO-friendly i18n routes in Next.js 13
Published: 2023-03-29 11:54:21

Next.js [official guide](https://beta.nextjs.org/docs/guides/internationalization#routing-overview) 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](https://dev.to/svobik7/dont-use-dynamic-lang-segment-for-your-i18n-nextjs-routes-3k05) for more details).

---

### TL;DR

Use [next-roots](https://github.com/svobik7/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:

1. Home page: `/en`, `/es`, `/cs`
2. Article detail: `/en/[id]`, `/es/[id]`, `/cs/[id]`
3. Login page: `/en/login`, `/es/acceso`, `/cs/prihlaseni`
4. Signup page: `/en/signup`, `/es/registrar`, `/cs/registrace`

---

### Installing [next-roots](https://github.com/svobik7/next-roots) package

To be able to generate translated routes we need to install additional package called next-roots.

1. `yarn add next-roots`

It is also required to add ESBuild into devDependencies to be able to compile i18n configuration files for next-roots

2. `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`.

```bash
├── 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.

```js
// 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.

```bash
├── 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:

```js
// 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: 

```js
// 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:

```js
// 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:

```bash
├── 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](https://github.com/svobik7/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.

```ts
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](https://github.com/svobik7/next-roots) package can be pretty handy.