Table of Contents
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.
Create a new ‘Single Type’ in the ‘Content-Types Builder’ and name it ‘Global’.
Add a single component called ‘navbar’ as a field for Global.
Add a repeatable component called ‘link’ as a field for ‘navbar’.
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:
when using option catch-all routes such as [[...slug]] or [...slug], an array is required.
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 innext.config.js
.defaultLocale
which is the defaultLocale set innext.config.js
which in our case is 'en'.params
is the params passed fromgetStaticPaths
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:
Sending a graphQL request to Strapi to search by
slug
andlocale
:
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, }, });
We then extract the
title
andbody
from the API request:
const page = data.pages[0]; const { title, body } = page;
Then we create a
pageContext
object. This object is very important for managing localization on the site so pay special attention to this section. ThepageContext
object includes the props for the actual page along with an array oflocalizations
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" } ]
You may have then noticed that we then call 2 more functions
getLocalizedPaths(pageContext)
andgetGlobalData(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.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 :-)