How to fully localize your site by implementing Next.js localized routes

Updated on February 23, 2024

How to fully localize your site by implementing Next.js localized routes

I have wanted to localise one of my Next JS affiliate websites for a while but previously I could not work out how to do so with localised route names.

For example, for a website localised for English and Spanish with localised routes, we would aim to have the following pages:

English: ‘/second-page'
Spanish: ‘/es/segunda-pagina’

For me personally, I don’t feel that websites are fully localized without localized routes.

This is especially important when you are targeting international SEO.

It’s not really feasible in my opinion to accept the following as a viable solution:

English: ‘/second-page’
Spanish: ‘/es/second-page’

I suppose it depends on use case and whilst I am sure some individuals and companies are perfectly happy with non-translated routes, for an affiliate site that requires organic traffic to thrive, it would feel like a waste of time and investment to localize the site without translating the routes.

Therefore, in this tutorial, I will explain how I managed to solve the riddle of Next localized routes using Strapi as the CMS and using the Strapi GraphQL API.

We will create a Next website that has localized routes for English, Spanish and German.

I will be using google translate for this website so please do not be offended by my poor linguistic skills :-)

Prerequisites

To follow along with this tutorial, you should be familiar with NextJS and Strapi.

This is what the finished website will look like:

Demo https://fe-next-js-localized-routes.vercel.app

Set up

Firstly, create both the Next frontend and the Strapi backend inside a new folder.

mkdir next-localsied-routes-with-strapi
npx create-strapi-app backend –quickstart
npx create-next-app frontend 

Strapi backend

We will set up Strapi CMS first.

Once you have installed and registered a Strapi app, firstly go to settings > internationalization and add two extra locales: ‘es’ and ‘de’.

Also, since we will be using the graphQL API from Strapi you will need to go to Marketplace and download the graphQL plugin.

Collection Types

Firstly, we are going to set up two collection types: Blog and Page.

We will use the Page collection type to create four different pages which will be available in all our locales.

In a real-world app, the Page collection could be used for all standard pages such as /home, /contact, /pricing, /about etc

We will also create one Blog article in the Blog collection to show that along with standard pages, we can also localize content which lives in different folders (this will make more sense once we start on the Next frontend)..

Collection Type - Page

Create a new collection type in Strapi called ‘page’ and give it three fields: ‘title’ (Text), ‘slug’ (Text) and ‘body’(Text) and make sure to enable localisation on this type.

Collection Type – Blog

Do the same as above and create a collection type called ‘blog’ and give it the same fields as above ‘title’ (Text), ‘slug’ (Text) and ‘body’(Text).

Please note that Page and Blog do not have to have the same field types and there is no relevance for this tutorial in them having the same field types! I am simply including Blog so that I can demonstrate that it is possible to have more than one type of dynamic page in NextJS.

Adding content to the page and blog models.

Now we need to add some content for the pages and blogs.

We are going to create 4 pages in each locale and 1 blog in each locale.

Pages

Title: Home, slug: ‘/’, locale: ‘en’
Title: Casa, slug: ‘’/, locale: ‘es’
Title: Heimat, slug: ‘/’, locale: ‘de’

Title: First Page, slug: first-page, locale: ‘en’
Title: Primera pagina, slug: primera-pagina, locale: ‘es’
Title: Erste Seite, slug: erste-seite, locale: ‘de’

Title: Second Page, slug: second-page, locale: ‘en’
Title: Segunda pagina, slug: segunda-pagina, locale: ‘es’
Title: Zweite Seite, slug: zweite-seite, locale: ‘de'

Title: Third Page, slug: third-page, locale: ‘en’
Title: Tercera pagina, slug: tercera-pagina, locale: ‘es’
Title: Dritte Seite, slug: dritte-seite, locale: ‘de’

Blogs

Title: How to localise your website, slug: how-to-localise-your-website, locale: 'en'.
Title: Como localizar su sitio web, slug: como-localizar-su-sitio-web, locale: ‘es’.
Title: So lokalisieren Sie Ihre Website, slug: so-lokalisieren-sie-Ihre-website, locale: ‘de’.

I am going to use some real content from English, German and Spanish online newspapers for the body element of the pages and the blogs to save time creating dummy content.

Once you have entered the content for our 4 pages in all locales, your pages collection should look like this below:

Once you have entered the content for our blog post, the blog collection should look like this below:

Single Type - Global

Create a ‘Single Type’ which we will call ‘Global’ to manage the navbar for this tutorial (and any other global data you wish to manage).

N.B make sure to go to advanced settings and select Enable localization for this Content-Type and then follow the steps below.

  1. Create a new ‘Single Type’ in the ‘Content-Types Builder’ and name it ‘Global’.

  2. Add a single component called ‘navbar’ as a field for Global.

  3. Add a repeatable component called ‘link’ as a field for ‘navbar’.

  4. Add the following fields to the link component: ‘name’ (Text), ‘url’ (Text), newTab (Boolean)

Your Global type should now look like this below.

We will now add some data.

Add in 4 link components to your navbar component in the default locale (English en).

Now we can complete the navbar data by adding in the links for our two extra locales (de and es). Here is the 'es' version:

Once this is done, we will have the following links set up in our navbar component inside the Global single-type.

English-en

Name: Home, URL: ‘/’
Name: First Page, URL: /first-page
Name: Second Page, URL: /second-page
Name: Third Page, URL: /third-page
Name: How to localise your website, URL: /blog/how-to-localise-your-website

Spanish-es

Name: Casa, URL: ‘/’
Name: Primera pagina, URL: /primera-pagina
Name: Segunda pagina, URL: /segunda-pagina
Name: Tercera pagina, URL: /tercera-pagina
Name: Como localizar su sitio web, URL: /blog/como-localizar-su-sitio-web

German-de

Name: Heimat, URL: ‘/’
Name: Erste Seite, URL: /erste-seite
Name: Zweite Seite, URL: /zweite-seite
Name: Dritte Seite, URL: /dritte-seite
Name: So lokalisieren Sie Ihre Website, URL: /blog/so-lokalisieren-sie-Ihre-website

Note that for the home page for each locale, we have provided '/' as the value for the URL.

The final thing we need to do to complete the set up of Strapi is to go to settings > Users & Permissions > Roles > Public and then make sure all our content types enable find, findone and count enabled.

NextJS Frontend

Firstly, install the dependencies we’ll need to build our front end.

npm i @apollo/client graphql bulma sass js-cookie react-markdown

Project set up

Let’s start by building out the structure of the Next app.

Create the following components:

mkdir components
touch components/layout.jsx
touch components/locale-switch.jsx
touch components/navbar.jsx

Create a /lib folder and an apollo-client.js helper file.

mkdir lib
touch lib/apollo-client.js

In /lib/apollo-client.js paste the following to make the connection to our GraphQL api:

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
    uri: 'http://localhost:1337/graphql',
    cache: new InMemoryCache(),
});

export default client;

We will also need a /utils folder to include api helpers and localization helpers:

mkdir utils
touch utils/api-helpers.js
touch utils/localize-helpers.js

Finally we need to create a next.config.js in the root of our project so we can set up i18n for our selected locales:

module.exports = {
    i18n: {
        locales: ['en', 'de', 'es'],
        defaultLocale: 'en',
    },
};

As we will be fetching our content for our home page from Strapi, we do not need an index.js page.

If we left the index.jsx page inside the pages folder, we would get the following error message:

Error: You cannot define a route with the same specificity as a optional catch-all route ("/" and "/[[...slug]]")

Therefore, In the pages folder, please delete index.js.

We will need the following pages set up in Next to implement our localization solution:

mkdir pages/blog
touch pages/blog/[slug].jsx
touch pages/[[...slug]].jsx

[[...slug]].jsx

Let's start with the elephant in the room..

The [[...slug]].jsx page is the official Next.js way of creating an optional catch-all route, which is documented on their website here.

We will use this catch-all route to fetch all of the pages that we created for each locale in Strapi.

We will use the pages/[blog].jsx page to fetch blog posts separately.

Note: whilst it is possible to fetch every single page from Strapi via the [[...slug]].jsx dynamic page I personally think it is better from an organisational perspective to split the content via each folder that requires dynamic content.

Therefore I prefer to use the catch-all route to fetch all the root pages that fall outside of any other subdirectory (usually pages such as /about and /contact will not need to be within another folder).

Setting up [[...slug]].jsx

Import the required dependencies:

import { gql } from '@apollo/client';
import client from '../lib/apollo-client';

The first step inside of [[...slug]].jsx is to fetch all of the pages per locale from Strapi.

To do this, we will utilise the built-in Next function getStaticPaths.

In the first section of getStaticPaths we map through the locales to fetch the pages per locale:

export async function getStaticPaths({ locales }) {
    // array of locales provided in context object in getStaticPaths
    const paths = (await Promise.all(
        locales.map(async (locale) => {
            // map through locales
            const { data } = await client.query({
                query: gql`
                    query GetAllPages($locale: String) {
                        pages(locale: $locale) {
                            slug
                            locale
                        }
                    }
                `, // fetch list of pages per locale
                variables: { locale },
            });
            return {
                pages: data.pages,
                locale,
            };
        })
    ))

On it's own, this will return an array of pages for each locale and the locale itself, like below:

[
  { pages: [ [Object], [Object], [Object], [Object] ], locale: 'en' },
  { pages: [ [Object], [Object], [Object], [Object] ], locale: 'de' },
  { pages: [ [Object], [Object], [Object], [Object] ], locale: 'es' }
]

However, Next expects a certain value to be returned from getStaticPaths which is { paths: [], fallback: boolean }.

Therefore, we then need to reduce through the array above to extract the values we need.

export async function getStaticPaths({ locales }) {
    // array of locales provided in context object in getStaticPaths
    const paths = (
        await Promise.all(
            locales.map(async (locale) => {
                // map through locales
                const { data } = await client.query({
                    query: gql`
                        query GetAllPages($locale: String) {
                            pages(locale: $locale) {
                                slug
                                locale
                            }
                        }
                    `, // fetch list of pages per locale
                    variables: { locale },
                });
                return {
                    pages: data.pages,
                    locale,
                };
            })
        )
    ).reduce((acc, item) => {
        item.pages.map((p) => {
            // reduce through the array of returned objects
            acc.push({
                params: {
                    slug: p.slug === '/' ? false : p.slug.split('/'),
                },
                locale: p.locale,
            });
            return p;
        });
        return acc;
    }, []);

    return {
        paths,
        fallback: false,
    };
}

Notice that in the params object that we return to the reduce function, we are formatting the slug as either false or an array using split('/').

This is due to the fact that:

  1. when using option catch-all routes such as [[...slug]] or [...slug], an array is required.

  2. We need to fetch the data for the home page for each locale which all have '/' as the slug.

getStaticPaths is now returning the desired array:

[
  { params: { slug: [Array] }, locale: 'en' },
  { params: { slug: [Array] }, locale: 'en' },
  { params: { slug: [Array] }, locale: 'en' },
  { params: { slug: false }, locale: 'en' },
  { params: { slug: [Array] }, locale: 'de' },
  { params: { slug: [Array] }, locale: 'de' },
  { params: { slug: [Array] }, locale: 'de' },
  { params: { slug: false }, locale: 'de' },
  { params: { slug: [Array] }, locale: 'es' },
  { params: { slug: [Array] }, locale: 'es' },
  { params: { slug: [Array] }, locale: 'es' },
  { params: { slug: false }, locale: 'es' }
]

We have successfully generated all the paths for all the locale pages and Nextjs will now call getStaticProps for each generated path.

Setting up getStaticProps for [[...slug]].jsx

Once Next.js has finished the getStaticPaths call, it will then iterate over all the paths provided and call getStaticProps for each one.

getStaticProps receives a context object which contains the following properties:

  • locale which is the locale for the path that it is fetching.

  • locales which is an array of all locales enabled in next.config.js.

  • defaultLocale which is the defaultLocale set in next.config.js which in our case is 'en'.

  • params is the params passed from getStaticPaths i.e {.params: { slug }, locale }

We now need to fetch the props (title, slug, body) for each individually generated page from Strapi.

Strapi also provides a localizations array for each page will includes the id of the different locale versions of each page.

Here is the finished getStaticProps function:

export async function getStaticProps({
    locale,
    locales,
    defaultLocale,
    params,
}) {
    const { data } = await client.query({
        query: gql`
            query GetPageBySlug($slug: String, $locale: String) {
                pages(locale: $locale, where: { slug: $slug }) {
                    title
                    body
                    slug
                    locale
                    localizations {
                        id
                        slug
                        locale
                    }
                }
            }
        `,
        variables: {
            slug: params.slug ? params.slug[0] : '/',
            locale,
        },
    });

    const page = data.pages[0];
    const { title, body } = page;

    const pageContext = {
        locale: page.locale,
        localizations: page.localizations,
        locales,
        defaultLocale,
        slug: params.slug ? params.slug[0] : '',
    };

    const localizedPaths = getLocalizedPaths(pageContext);
    const globalData = await getGlobalData(locale);

    return {
        props: {
            global: globalData,
            title,
            body,
            pageContext: {
                ...pageContext,
                localizedPaths,
            },
        },
    };
}

In the code above we are:

  1. Sending a graphQL request to Strapi to search by slug and locale:

    const { data } = await client.query({
        query: gql`
            query GetPageBySlug($slug: String, $locale: String) {
                pages(locale: $locale, where: { slug: $slug }) {
                    title
                    body
                    slug
                    locale
                    localizations {
                        id
                        slug
                        locale
                    }
                }
            }
        `,
        variables: {
            slug: params.slug ? params.slug[0] : '/',
            locale,
        },
    });
  1. We then extract the title and body from the API request:

    const page = data.pages[0];
    const { title, body } = page;
  1. Then we create a pageContext object. This object is very important for managing localization on the site so pay special attention to this section. The pageContext object includes the props for the actual page along with an array of localizations provided by our Strapi API..

    const pageContext = {
        locale: page.locale,
        localizations: page.localizations,
        locales,
        defaultLocale,
        slug: params.slug ? params.slug[0] : '',
    };

The localizations array provided by Strapi API for each page will look like this:

"localizations": [
          {
            "id": "1",
            "slug": "first-page",
            "locale": "en"
          },
          {
            "id": "7",
            "slug": "erste-seite",
            "locale": "de"
          }
        ]
  1. You may have then noticed that we then call 2 more functions getLocalizedPaths(pageContext) and getGlobalData(locale). Don't worry about these 2 for now - we won't ignore these functions as they are super important and we will explore them in detail below.

  2. We then return the full page props to the page component (we haven't set up the page component yet).

return {
        props: {
            global: globalData,
            title,
            body,
            pageContext: {
                ...pageContext,
                localizedPaths,
            },
        },
    };

What is getLocalizedPaths?

When we first set the project up, we created a file called localize-helpers.js inside our /utils folder.

Inside localize-helpers.js we need to create the following functions:

export const formatSlug = (slug, locale, defaultLocale) =>
    locale === defaultLocale ? `/${slug}` : `/${locale}/${slug}`; // if locale DOES NOT equal defaultLocale - en - it prepends the locale i.e /es/ or /de/

export const getLocalizedPaths = (pageContext) => {
    const { locales, defaultLocale, localizations, slug } = pageContext;
    // let's say that the pageContext for this call is 'es' locale version of 'first-page' so the slug will be 'primera-pagina'
    // Therefore the pageContext will look like this:
    // {
    //     locale: 'es',
    //     localizations: [
    //         {
    //           'id': '1',
    //           'slug': 'first-page',
    //           'locale': 'en'
    //         },
    //         {
    //           'id': '7',
    //           'slug': 'erste-seite',
    //           'locale': 'de'
    //         }
    //     ],
    //     locales: ['en', 'es', 'de'],
    //     defaultLocale: 'en',
    //     slug: 'primera-pagina'
    // };

    const paths = locales.map((locale) => {
        // map through all locales enabled in next.config.js ['en', 'es', 'de']
        if (localizations.length === 0)
            return {
                // if there is no localizations array provided by Strapi, we just return the defaultLocale page for all locales
                locale,
                href: formatSlug(slug, locale, defaultLocale), // format href so that it does not prepend /es or /de to the page
            };
        return {
            // if localizations array provided by Strapi return an object with locale and formatted href
            locale,
            href: localizePath({ ...pageContext, locale }), // object assign using spread which overrides locale in pageContext to mapped locale from next.config.js, which in our case will be either of 'es', 'en' or 'de'
        };
    });
    return paths;
};

export const localizePath = (pageContext) => {
    // This will be called 3 times for 'es', 'en' and 'de'.
    // Let's say for this function call, it is called with pageContext.locale = 'de'
    const { locale, defaultLocale, localizations, slug } = pageContext;
    let localeFound = localizations.find((a) => a.locale === locale); // it will look in the localizations array of the 'primera-pagina' page
    if (localeFound) return formatSlug(localeFound.slug, locale, defaultLocale);
    // if a 'de' version of the page is found, it will call formatSlug with the 'de' slug which is 'erste-seite'
    else return formatSlug(slug, locale, defaultLocale); // otherwise just return the default 'en' page
};

The end goal of the functions above is to return an array like the one below and add the localizedPaths to pageContext of every generated page:

[
  { locale: 'en', href: '/first-page' },
  { locale: 'de', href: '/de/erste-seite' },
  { locale: 'es', href: '/es/primera-pagina' }
]

What is getGlobalData?

Global data is the function used to fetch the global data (in our case our navbar) for each locale.

We fetch this on every page so that when the pages are statically generated, each page will have the navbar in the correct language.

Since we will need to use this function in a number of different places, we will put it inside our /utils/api-helpers.js file.

import gql from 'graphql-tag';
import client from '../lib/apollo-client';

export const getGlobalData = async (locale) => {
    const { data } = await client.query({
        query: gql`
            query GetGlobal($locale: String) {
                global(locale: $locale) {
                    locale
                    navbar {
                        links {
                            text
                            url
                            newTab
                        }
                    }
                }
            }
        `,
        variables: {
            locale,
        },
    });

    return data.global;
};

Firstly, we call getGlobalData with the locale as a parameter and then we fetch the navbar and links according to the locale global(locale: $locale).

We then return the full page props (including global and localizedPaths) to the Page component.

return {
        props: {
            global: globalData,
            title,
            body,
            pageContext: {
                ...pageContext,
                localizedPaths
            },
        },
    };

Page components

Now that we have successfully statically generated all the content and props for each page, we need to create the Page component and pass it what it needs.

DynamicPage component

Create a DynamicPage component with the title and body wrapped inside a Layout component and pass the global data and pageContext to the Layout.

const DynamicPage = ({ global, pageContext, title, body }) => {
    return (
        <Layout global={global} pageContext={pageContext}>
            <div>
                <h1>{title}</h1>
                <p>{body}</p>
            </div>
        </Layout>
    );
};

Layout component

Create a Layout component and import the Next built in useRouter function and the Navbar (we are yet to create the Navbar).

import { useRouter } from 'next/router';
import { formatSlug } from '../utils/localize';
import Navbar from './navbar';

const Layout = ({ children, pageContext, global }) => {
    const router = useRouter();
    const { locale, locales, defaultLocale, asPath } = router;
    const page = pageContext // if there is no pageContext because it is SSR page or non-CMS page
        ? pageContext
        : {
              // the following is from useRouter and is used for non-translated, non-localized routes
              locale, // current locale
              locales, // locales provided by next.config.js
              defaultLocale, // en = defaultLocale
              slug: formatSlug(asPath.slice(1), locale, defaultLocale), // slice(1) because asPath includes /
              localizedPaths: locales.map((loc) => ({
                  // creates an array of non-translated routes such as /normal-page /es/normal-page /de/normal-page. Will make more sense when we implement the LocaleSwitcher Component
                  locale: loc,
                  href: formatSlug(asPath.slice(1), loc, defaultLocale),
              })),
          };

    return (
        <div>
            <Navbar pageContext={page} navbar={global.navbar} />
            {children}
        </div>
    );
};

export default Layout;

The Layout component is used to provide the Navbar and any other global components with the necessary content.

Note that due to the fact we want our Layout to be persistent throughout the site, we are using a check to see if pageContext exists.

This is simply the design I have implemented for this site.

For a site that is 100% static, there is no need to have the if/else ternary operator.

If pageContext does not exist we provide the properties from the router.

An alternative to this way of doing things is to create separate Layout and Navbar components for statically generated pages and non-statically generated pages.

Navbar component

Create the Navbar component and use the global data to map through and render the links.

We also need to pass the pageContext through to the LocaleSwitch component.

import LocaleSwitch from './locale-switch';
import Link from 'next/link';

export default function Navbar({ pageContext, navbar }) {
    return (
        <div>
            <nav>
                {navbar.link.map((link) => (
                    <Link
                        key={link.url}
                        href={link.url}
                        locale={pageContext.locale}>
                        <a>
                            {' '}
                            <span>{link.name}</span>{' '}
                        </a>
                    </Link>
                ))}
            </nav>
            <LocaleSwitch pageContext={pageContext} />
        </div>
    );
}

LocaleSwitch component

One of the final parts of the jigsaw is to create a LocaleSwitch component which we will use to manage locales.

The full code of the component is below. I will explain each section underneath.

import Cookies from 'js-cookie';
import { useRouter } from 'next/router';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { getLocalizedPage, localizePath } from '../utils/localize-helpers';

export default function LocaleSwitch({ pageContext }) {
    const isMounted = useRef(false); // We utilise useRef here so that we avoid re-render once it is mounted
    const router = useRouter();
    const [locale, setLocale] = useState();

    const handleLocaleChange = async (selectedLocale) => {
        Cookies.set('NEXT_LOCALE', selectedLocale); // set the out-of-the-box Next cookie 'NEXT_LOCALE'
        setLocale(selectedLocale);
    };

    const handleLocaleChangeRef = useRef(handleLocaleChange); // use a ref so that it does not re-render unless necessary. Note we are using handleLocaleChange(locale) without the ref in our Link components below

    useEffect(() => {
        const localeCookie = Cookies.get('NEXT_LOCALE');
        if (!localeCookie) {
            // if there is no NEXT_LOCALE cookie set it to the router.locale
            handleLocaleChangeRef.current(router.locale);
        }

        const checkLocaleMismatch = async () => {
            if (
                // if localeCookie IS SET and does not match pageContextlocale
                !isMounted.current &&
                localeCookie &&
                localeCookie !== pageContext.locale
            ) {
                // For example if localeCookie = 'es' and user lands on /de/erste-seite, it will call getLocalizedPage with 'es' and pageContext
                const localePage = await getLocalizedPage(
                    localeCookie,
                    pageContext
                ); // we then fetch the correct localized page

                // object assign overrides locale, localizations, slug
                router.push(
                    // router.push the correct page which is /es/primera-pagina
                    `${localizePath({ ...pageContext, ...localePage })}`, //url
                    `${localizePath({ ...pageContext, ...localePage })}`, // as
                    { locale: localePage.locale } // options
                );
            }
        };

        setLocale(localeCookie || router.locale);
        checkLocaleMismatch();

        return () => {
            // sets the ref isMounted to true which will persist state throughout.
            isMounted.current = true;
        };
    }, [locale, router, pageContext]); // called again if locale, router or pageContext change

    return (
        <>
            <div>
                {pageContext.localizedPaths && // only render the language switcher if current page is localized
                    pageContext.localizedPaths.map(({ href, locale }) => {
                        return (
                            <Link
                                href={href}
                                locale={locale}
                                key={locale}
                                role={'option'}
                                passHref>
                                <a onClick={() => handleLocaleChange(locale)}>
                                    <span> {locale} </span>
                                </a>
                            </Link>
                        );
                    })}
            </div>
        </>
    );
}

We begin by declaring isMounted = useRef(false). The reason we use useRef is so that we are not triggering unnecessary re-renders.

We do the same again for the function that we will use to change the locale handleLocaleChange.

In useEffect, we are firstly checking if the provided NEXT_LOCALE cookie is active. If it is not active, we set the cookie to the value of router.locale using the ref function handleLocaleChangeRef. The reason we use a ref here is again so that we do not trigger re-render. At this stage there is no need to re-render.

We then set the locale in our state using setLocale(localeCookie || router.locale) and then we call the function checkLocaleMismatch before setting isMounted.current = true.

What is checkLocaleMismatch()?

This function is used to check whether the current page is the correct page for a given localeCookie.

For example, let's say that your localeCookie is set to 'en' and you land on the page /de/erste-seite which is a German version.

The checkLocaleMismatch function checks if isMounted.current = false and whether localeCookie is different to pageContext.locale.

Note:isMounted would be false if the page had been reloaded or clicked onto from elsewhere.

If the conditions are met, we then call a new function called getLocalizedPage and pass the localeCookie and pageContext.

We need to create the function getLocalizedPage in /utils/localize-helpers:

export const getLocalizedPage = async (targetLocale, pageContext) => {
    const localization = pageContext.localizations.find(
        (localization) => localization.locale === targetLocale
    );
    const { data } = await client.query({
        query: gql`
            query getPage($id: ID!) {
                page(id: $id) {
                    title
                    body
                    slug
                    locale
                    localizations {
                        id
                        slug
                        locale
                    }
                }
            }
        `,
        variables: {
            id: localization.id,
        },
    });
    return data.page;
};

We pass the parameters targetLocale which in this case is en as en is the localeCookie and we pass the pageContext of the /de/erste-seite page.

This function firstly finds the target locale within the localizations array of the original page (/de/erste-seite) page.

We then query the API for the id of the found localization (en) and return the correct localized page and send this back to checkLocaleMismatch.

The function then uses router.push and localizePath (to override locale, localizations and slug) to redirect us to the correct page for en which in this case would be /first-page.

You can see this in action once the website is completed by checking what the NEXT_LOCALE value is in your browser and then directly typing a different locale page into the browser.

Our website will redirect us to the correct locale page as determined by the localeCookie.

For example if NEXT_LOCALE is set to 'en' and we type /de/erste-seite into the browser, it will redirect us to /first-page.

Finally, in our LocaleSwitcher component, we render all the localizedPaths from pageContext which will allow us to skip between locale pages.

That's it! Now if you run npm run build and then npm run start and fire up the website, you can test out your fully localized website with localized routes and locale switcher!

Hopefully, you've found this tutorial useful!

Additional steps

The tutorial above is more than enough to build a fully localized website with Next.js and Strapi.

However there is a few things that I will do to complete the tutorial

Blog pages

Remember the Blog Content-Type in Strapi? Feels ages ago now! One last thing I wanted to demonstrate with this is that Next.js generates routes by specificity.

Therefore, generating static pages from blog/[slug].jsx will not be overridden by [[...slug]].jsx.

The full code for blog/[slug].jsx is below:

import { gql } from '@apollo/client';
import Layout from '../../components/layout';
import client from '../../lib/apollo-client';
import { getGlobalData } from '../../utils/api-helpers';
import { getLocalizedPaths } from '../../utils/localize-helpers';
import ReactMarkdown from 'react-markdown';
import Head from 'next/head';

export default function DynamicBlog({ pageContext, global, title, body }) {
    return (
        <>
            <Head>
                {pageContext.localizedPaths.map((p) => (
                    <link
                        key={p.locale}
                        rel='alternate'
                        href={p.href}
                        hrefLang={p.locale}
                    />
                ))}
            </Head>
            <Layout global={global} pageContext={pageContext}>
                <div>
                    <h1>{title}</h1>
                    <ReactMarkdown>{body}</ReactMarkdown>
                </div>
                {pageContext.localizedPaths.map((p) => {
                    return (
                        <div key={p.locale}>
                            {p.href} {p.locale}
                        </div>
                    );
                })}
            </Layout>
        </>
    );
}

export async function getStaticPaths({ locales }) {
    const paths = (
        await Promise.all(
            locales.map(async (locale) => {
                const { data } = await client.query({
                    query: gql`
                        query GetBlogs($locale: String) {
                            blogs(locale: $locale) {
                                slug
                                locale
                            }
                        }
                    `,
                    variables: { locale },
                });
                return {
                    pages: data.blogs,
                    locale,
                };
            })
        )
    ).reduce((acc, item) => {
        item.pages.map((p) => {
            acc.push({
                params: {
                    slug: p.slug,
                },
                locale: p.locale,
            });
            return p;
        });
        return acc;
    }, []);

    return {
        paths,
        fallback: false,
    };
}

export async function getStaticProps({
    locales,
    locale,
    defaultLocale,
    params,
}) {
    const globalData = await getGlobalData(locale);
    const { data } = await client.query({
        query: gql`
            query GetBlog($slug: String, $locale: String) {
                blogs(locale: $locale, where: { slug: $slug }) {
                    title
                    slug
                    body
                    locale
                    localizations {
                        id
                        slug
                        locale
                    }
                }
            }
        `,
        variables: {
            slug: params.slug,
            locale,
        },
    });

    const page = data.blogs[0];
    const { title, body } = page;

    const pageContext = {
        locale: page.locale,
        locales,
        defaultLocale,
        slug: params.slug,
        localizations: page.localizations,
    };

    const localizedPaths = getLocalizedPaths({ ...pageContext }).map((path) => {
        let arr = path.href.split('');
        const index = arr.lastIndexOf('/') + 1;
        arr.splice(index, 0, 'blog/').join('');
        path.href = arr.join('');
        return path;
    });

    return {
        props: {
            global: globalData,
            title,
            body,
            pageContext: {
                ...pageContext,
                localizedPaths,
            },
        },
    };
} 

The main difference between /blog/[slug].jsx and [[...slug]]].jsx is that in /blog/[slug].jsx we just add an extra .map function on our localizedPaths function so that we can add the blog/ segment into the href for each path.

const localizedPaths = getLocalizedPaths({ ...pageContext }).map((path) => {
    let arr = path.href.split('');
    const index = arr.lastIndexOf('/') + 1;
    arr.splice(index, 0, 'blog/').join('');
    path.href = arr.join('');
    return path;
});

React Markdown

At the moment, our body text from both Pages and Blogs is not formatted as HTML.

To fix this simply pass the body and title as children of ReactMarkdown component:

const DynamicPage = ({ global, pageContext, title, body }) => {
    return (
        <Layout global={global} pageContext={pageContext}>
            <div>
                <h1>{title}</h1>
                <ReactMarkdown>{body}</ReactMarkdown>
            </div>
        </Layout>
    );
};

Use hreflang Tags

Due to the fact that one of the main reasons for using localized slugs/routes is for SEO benefits, we need to create href lang tags in our pages.

Fortunately we can access all the information we need for this in our pageContext.localizedPaths array.

Simply use next/head to create your hreflang tags:

<Head>
    {pageContext.localizedPaths.map((p) => (
        <link
            key={p.locale}
            rel='alternate'
            href={p.href}
            hrefLang={p.locale}
        />
    ))}
</Head>

Styling with Bulma CSS

The styling is outside the scope of this tutorial but I have added it for the demo.

See the full code at Github

https://github.com/mckennapaul27/FE-next-js-localized-routes

https://github.com/mckennapaul27/BE-next-js-localized-routes

View the live demo

https://fe-next-js-localized-routes.vercel.app

Have a great day ahead :-)

Have a project or idea? Get in touch.

I'm always interested in any opportunity whether that's freelance work, consulting work or employment opportunities. If you have a project that you want to get started, think you need my help with something or just fancy connecting, then please get in touch!

Contact me
Paul McKenna Web Developer Profile Pic
Find me on