Making a multilingual site with Next.js - Part 3
Elves Sousa
Posted on April 5, 2021
If you ended up here for this third part and did not see the first nor the second, I highly suggest you take a look at those first. In the previous section, we dealt with the creation and listing of content for the languages and ended the project there.
However, some commented that it would be interesting to add translated slugs, for example: in English the "about" page open at site.com/en/about
and its corresponding Portuguese version open at site.com/pt/sobre
. In this article, I show you how we can create such functionality. Let's start!
But first...
In the previous articles, the function for changing languages was implemented. But when the page was refreshed, it returned to the default language, which caused a certain annoyance. This behavior is not the best, so it is important to solve this issue. Fortunately, it is not difficult at all to implement, with just a few lines of code.
Local Storage
Local Storage is a way that JavaScript provides us to save information in the user's browser, so that it will be available on a next visit. Many use it to make simple authentication or to save options, such as light and dark modes, for example.
The logic used here does not differ from that of a theme change, the change is that language will be saved instead. Small modifications to only two files are needed. The files are: the Header
component and theLanguageProvider
language context. If you fell from another dimension and did not see the two previous articles and nothing makes sense to you until now, I warned you at the beginning of the article! Go there and check the previous articles, and then come back here!
Here is the code for the Header component:
import { useContext } from "react"
import { useRouter } from "next/router"
import Navigation from "../Navigation"
import Logo from "../Logo"
import { LanguageContext, locales } from "../../intl/LanguageProvider"
interface Props {
className?: string
children?: React.ReactNode
}
const Header: React.FC<Props> = ({ className, children }) => {
const headerClass = className || "header"
const [locale, setLocale] = useContext(LanguageContext)
const router = useRouter()
function handleLocaleChange(language: string) {
if (!window) {
return
}
const regex = new RegExp(`^/(${locales.join("|")})`)
localStorage.setItem("lang", language) // This line saves the language option!
setLocale(language)
router.push(router.pathname, router.asPath.replace(regex, `/${language}`))
}
return (
<header className={headerClass}>
<Logo link={`/`} />
<Navigation />
{children}
<div className="lang">
<button onClick={() => handleLocaleChange("en")}>EN</button>
<button onClick={() => handleLocaleChange("pt")}>PT</button>
</div>
</header>
)
}
export default Header
In Header, the method localStorage.setItem ('lang', language)
was used to save the language choice by clicking on the corresponding button. What this method does is basically add a 'lang' key with the acronym of the chosen language. You can check this in the Application area of your browser's inspector, in the Local Storage section.
The LanguageProvider is as follows:
import { createContext, useEffect, useState } from "react"
export const defaultLocale = "pt"
export const locales = ["pt", "en"]
export const LanguageContext = createContext([])
export const LanguageProvider: React.FC = ({ children }) => {
const [locale, setLocale] = useState("pt")
useEffect(() => {
if (!window) {
return
}
// Captures the language information saved by the Header component
const language = localStorage.getItem("lang") || locale
setLocale(language)
}, [locale])
return (
<LanguageContext.Provider value={[locale, setLocale]}>
{children}
</LanguageContext.Provider>
)
}
Here the localStorage.getItem ('lang')
method captures the saved information from the language choice, and applies it if it exists. Now when updating the page, the language you selected stays there.
Finally... Let's create the translated slugs...
Nothing prevents you from creating files in the /pages
folder, with the desired title, such as /kontakt.tsx
for a contact page in German. It will work perfectly, but let's be honest: it is not the best way to do the job. We should be able to provide a way for pages to be created dynamically, with a standard template, changing the content and slug according to the language.
If you think about it, a similar thing is done with our posts area in this project. To achieve this, just modify the library we created for the posts (/lib/posts.ts
) to include our new translated pages. But avoid duplicate code, instead of creating a /lib/pages.ts
file with practically the same content as /lib/posts
, I decided to unify everything in a single library that I called lib/files.ts
.
The content of this file is as follows:
import fs from "fs"
import path from "path"
import matter, { GrayMatterFile } from "gray-matter"
import remark from "remark"
import html from "remark-html"
const postsDirectory = path.resolve(process.cwd(), "content", "posts")
const pagesDirectory = path.resolve(process.cwd(), "content", "pages")
// Collects all file names in the folders specified with the sctructure ['en/filename.md']
export function getAllFileNames(directoryPath: string, filesList = []) {
const files = fs.readdirSync(directoryPath)
files.forEach((file) => {
if (fs.statSync(`${directoryPath}/${file}`).isDirectory()) {
filesList = getAllFileNames(`${directoryPath}/${file}`, filesList)
} else {
filesList.push(path.join(path.basename(directoryPath), "/", file))
}
})
const filteredList = filesList.filter((file) => file.includes(".md"))
return filteredList
}
// Sorts posts by date
export function getSortedPostData() {
const fileNames = getAllFileNames(postsDirectory)
const allPostsData = fileNames.map((fileName) => {
const id = fileName.split("/")[1].replace(/\.md$/, "")
const fullPath = path.join(postsDirectory, fileName)
const fileContents = fs.readFileSync(fullPath, "utf-8")
const frontMatter: GrayMatterFile<string> = matter(fileContents)
return {
id,
...(frontMatter.data as {
lang: string
date: string
category: string
}),
}
})
return allPostsData.sort((a, b) => {
if (a.date < b.date) {
return 1
} else {
return -1
}
})
}
// IDs for posts or pages
export function getAllIds(type = "post") {
const dir = type === "page" ? pagesDirectory : postsDirectory
const fileNames = getAllFileNames(dir)
return fileNames.map((fileName) => ({
params: {
id: fileName.split("/")[1].replace(/\.md$/, ""),
lang: fileName.split("/")[0],
},
}))
}
// Collects data from the markdown file and makes it available
export async function getContentData(id: string, type = "post") {
const dir = type === "page" ? pagesDirectory : postsDirectory
const fullPath = path.join(dir, `${id}.md`)
const fileContents = fs.readFileSync(fullPath, "utf-8")
const frontMatter = matter(fileContents)
const processedContent = await remark().use(html).process(frontMatter.content)
const contentHtml = processedContent.toString()
return {
id,
...(frontMatter.data as { date: string; title: string }),
contentHtml,
}
}
I created a type
argument in some of the functions that will be used by both posts and pages. This because this argument identifies the directory in which the files will be read. By default, I left it configured to always search for posts. Since the file name has changed and so have the functions, it is necessary to update the imports in the files that use the new library.
Template for the dynamic page
Here is another page with a special name, to create a dynamic route. In this the parameter will be the 'id' of the file, which is captured by the function getAllIds()
of the file lib/files
. The file will be called [lang]/[id].tsx
. Below is the complete code of the file.
import { GetStaticProps, GetStaticPaths, NextPage } from "next"
import { getAllIds, getContentData } from "../../lib/files"
import Layout from "../../components/Layout"
interface PageProps {
locale: string
pageData: {
lang: string
title: string
slug: string
date: string
category?: string
contentHtml: string
}
}
const SitePage: NextPage<PageProps> = ({ pageData }) => {
const { title, contentHtml } = pageData
return (
<Layout title={title}>
<article className="post-content">
<h1>{title}</h1>
<div
className="post-text"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</article>
</Layout>
)
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
// Here is the argument to informa "page" as type,
// so Next.js can search for page files, ignoring posts.
const pageData = await getContentData(`/${params.lang}/${params.id}`, "page")
return {
props: {
locale: params?.lang || "pt",
pageData,
},
}
}
export const getStaticPaths: GetStaticPaths = async () => {
// Here is the argument to informa "page" as type,
// so Next.js can search for page files, ignoring posts.
const paths = getAllIds("page")
return {
paths,
fallback: false,
}
}
export default SitePage
With this file, it is already possible to support pages created through Markdown. The markdown files use the following structure:
---
lang: pt
title: "Sobre"
---
Site made to showcase the creation of a bilingual website using Next.js. The tutorial is in an article on my blog. Feel free to view the source code, fork it, or even use it in your projects.
To better organize the files, I created a directory called /content
in the root of the project, and in it another two: posts
and pages
. These will receive the markdown files in the directories for each language supported on the website. With the code presented here, the creation of the pages is fully automated and based on this structure.
Wrapping it up
I believe that now we already have a very functional example of a multilingual website using Next.js. You can create content for many languages and let the user choose one to use in your site.
Comments, suggestions and questions are welcome, leave it below. I also provided the link to the complete project repo on GitHub, in case you want to see the complete code. If you encounter an error, you can leave your issue there too.
See you!
Links
- Portuguese Version of this article
- First part of this tutorial
- Second part of this tutorial
- Repo on GitHub
- Site made with this code
If this article helped you in some way, consider donating. This will help me to create more content like this!
Posted on April 5, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.