Making a multilingual site with Next.js - Part 3

- 7 min

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 and its corresponding version open at 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) {

    const regex = new RegExp(`^/(${locales.join("|")})`)
    localStorage.setItem("lang", language) // This line saves the language option!

    router.push(router.pathname, router.asPath.replace(regex, `/${language}`))

  return (
    <header className={headerClass}>
      <Logo link={`/`} />
      <Navigation />
      <div className="lang">
        <button onClick={() => handleLocaleChange("en")}>EN</button>
        <button onClick={() => handleLocaleChange("pt")}>PT</button>

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) {
    // Captures the language information saved by the Header component
    const language = localStorage.getItem("lang") || locale
  }, [locale])

  return (
    <LanguageContext.Provider value={[locale, setLocale]}>

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/']
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 = => {
    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 {
      ...( as {
        lang: string
        date: string
        category: string

  return allPostsData.sort((a, b) => {
    if ( < {
      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 => ({
    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 {
    ...( as { date: string; title: string }),

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">
          dangerouslySetInnerHTML={{ __html: contentHtml }}

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}/${}`, "page")

  return {
    props: {
      locale: params?.lang || "pt",

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