DEV Community

Jirka Svoboda
Jirka Svoboda

Posted on

Step-by-step guide for SEO-friendly i18n routes in Next.js 13

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:

  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 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

  1. 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
└── ...
Enter fullscreen mode Exit fullscreen mode

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, 
}
Enter fullscreen mode Exit fullscreen mode

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
└── ...

Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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 },
  ]
}
Enter fullscreen mode Exit fullscreen mode

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' },
]
Enter fullscreen mode Exit fullscreen mode

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
└── ...

Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
leoschronicles profile image
Leonardo

Thanks for your hard work and making this available to the public!