Making a multilingual site with Next.js - Part 2

In the second part of the series, we will create markdown files for the site's contents in each language.


Elves Sousa

2 years ago | 9 min read

If you missed the first part of this article, I suggest you take a look at it before continuing reading this one. In order not to make the article too long, I chose to split it into two parts. In the previous part, we saw how to translate the words on screen. Now, we will deal with the creation and listing of content for each language. Without further ado, here we go!

Markdown content for each language

The file structure follows the example below:

---lang: pt

title: "Artigo em português"

slug: artigo

date: "2020-07-12"

category: post

description: "Lorem ipsum dolor sit amet consectetuer adispiscing elit"---

## Lorem

Lorem ipsum dolor sit amet consectetuer adispiscing elit.

If you don't know Markdown, this header between --- is called "frontmatter". With it, we pass information that will be used for the listing and display of the content. Below is a brief description of what each field does:

  • lang: ISO of the language used in the content.
  • title: title of the article.
  • date: date of the article, in YYYY-MM-DD format. Note that it is enclosed in quotation marks, otherwise Next.js throws an error.
  • description: summary of the article on the article listing page.
  • category: category of the article.

You have freedom to create your own fields in this header, like tags and stuff. For the example cited here, this is enough.

Library to read Markdown files

As you can already know, Markdown files are the basis of our content. To read these files and convert them to HTML, three packages need to be installed: Remark and Remark-HTML and Gray Matter. The latter reads the *.md file frontmatter.

In order to install it:

yarn add remark remark-html gray-matter

npm install --save remark remark-html gray-matter

This part was easy, however, creating the post loop is not that simple. First I followed the tutorial that the folks at Next.js did, but I had to make some adjustments to add the possibility of saving the files in different folders, by language. Below is the commented code.

import fs from "fs"import path from "path"import matter, { GrayMatterFile } from "gray-matter"import remark from "remark"import html from "remark-html"

// Directory used to read markdown filesconst postsDirectory = path.resolve(process.cwd(), "posts")

// Returns a list of files in the directories and// subdirectories in the formal ['en/']function getAllPostFileNames(directoryPath, filesList = []) {

const files = fs.readdirSync(directoryPath)

files.forEach((file) => {

if (fs.statSync(`${directoryPath}/${file}`).isDirectory()) {

filesList = getAllPostFileNames(`${directoryPath}/${file}`, filesList)

} else {

filesList.push(path.join(path.basename(directoryPath), "/", file))



// Filter to include only * .md files

// If you don't use this, even .DS_Stores are included

const filteredList = filesList.filter((file) => file.includes(".md"))

return filteredList


// Collects information from files and sorts them by dateexport function getSortedPostData() {

// Get the list of * .md files in the posts directory

const fileNames = getAllPostFileNames(postsDirectory)

// Uses gray-matter to collect information from the file

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




// Sorts collected information by date

return allPostsData.sort((a, b) => {

if ( < {

return 1

} else {

return -1



// Separates the file name and languageexport function getAllPostIds() {

// Get the list of * .md files in the posts directory

const fileNames = getAllPostFileNames(postsDirectory)

// Splits the "en" and "filename" parts of ['en/']

// and return them as parameters for later use in Next

return => ({

params: {

id: fileName.split("/")[1].replace(/\.md$/, ""),

lang: fileName.split("/")[0],



// Make the data available for the informed post.export async function getPostData(id) {

const fullPath = path.join(postsDirectory, `${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 }),



For those who have used Gatsby, this file is the equivalent of the gatsby-node.js file. It makes file data available for viewing in Next.js.

Listing posts

Next.js uses its own way of routing. Unlike Gatsby, where you define the routes of the listing pages in the gatsby-node.js file, you use the folder structure itself.

To have a URL, simply create the directories following this structure, inside the /pages folder that we already used to create the other pages.

If we just did something like suggested above, we would have the same result visually, but using React components instead of the .md files. In the end, we would have several *.tsx files and a folder for each language. This is not the best approach, though.

It makes a lot more sense to leave the content files in Markdown and use something dynamic to read this content and generate the static pages. Next.js can use the folder and file names to express a dynamic part of the route, using square brackets.

On the right, the way Next.js organizes dynamic routes

Instead of making the structure on the left, we will use the leaner version on the right. In this example, the file for listing files is articles.tsx. It is inside the /[lang] folder which will tell Next.js that the variable "lang" will be used at the URL:[lang]/articles. This [lang] will be replaced by pt or en according to the language to be displayed. Here is the code for the file:

import { useState } from "react"import { NextPage, GetStaticProps, GetStaticPaths } from "next"import Link from "next/link"

import Layout from "../../components/Layout"// Import function that lists articles by dateimport { getSortedPostData } from "../../lib/posts"import useTranslation from "../../intl/useTranslation"

interface Props {

locale: string

allPostsData: {

date: string

title: string

lang: string

description: string

id: any


const Post: NextPage<Props> = ({ locale, allPostsData }) => {

const { t } = useTranslation()

// Articles filtered by language

const postsData = allPostsData.filter((post) => post.lang === locale)

// Pagination

const postsPerPage = 10

const numPages = Math.ceil(postsData.length / postsPerPage)

const [currentPage, setCurrentPage] = useState(1)

const pagedPosts = postsData.slice(

(currentPage - 1) * postsPerPage,

currentPage * postsPerPage


// Date display options

const dateOptions = {

year: "numeric",

month: "long",

day: "numeric",


return (

<Layout className="posts" title={t("articles")}>

<section className="page-content">


{/* List of articles */}

{ => (

<article key={} className="post">

<Link href={`/[lang]/post/[id]`} as={`/${locale}/post/${}`}>






{new Date(, dateOptions)}


{post.description && <p>{post.description}</p>}



{/* Paging */}

{numPages > 1 && (

<div className="pagination">

{Array.from({ length: numPages }, (_, i) => (


key={`pagination-number${i + 1}`}

onClick={() => setCurrentPage(i + 1)}

className={currentPage === i + 1 ? "active" : ""}


{i + 1}








// Captures the information needed for the static pageexport const getStaticProps: GetStaticProps = async (ctx) => {

// All site articles

const allPostsData = getSortedPostData()

// Returns the properties used in the main component: the page

return {

props: {

locale: ctx.params?.lang || "pt", // Captures the language of [lang] route




// Generates static files on exportexport const getStaticPaths: GetStaticPaths = async () => {

// All supported languages must be listed in 'paths'.

// If not informed, the static page will not be generated.

return {

paths: [{ params: { lang: "en" } }, { params: { lang: "pt" } }],

fallback: false,


export default Post

As the intention is to generate static files, I used the getStaticProps() function to capture the information and getStaticPaths to inform the system the path where the pages will be exported.

Post page

Another page with the special file name, to inform a dynamic route. This time the parameter will be the file id, which is captured by the getAllPostIds() function of the lib/posts file, so the name of this component will be [lang]/posts/[id].tsx. Below, its contents:

import { GetStaticProps, GetStaticPaths, NextPage } from "next"

/* - getAllPostIds: Gets the file id, that is, the file name

markdown without the * .md extension

- getPostData: Collects information from a single article by the given id.

*/import { getAllPostIds, getPostData } from "../../../lib/posts"import Layout from "../../../components/Layout"

interface Props {

locale: string

postData: {

lang: string

title: string

slug: string

date: string

category: string

contentHtml: string


const Post: NextPage<Props> = ({ postData, locale }) => {

const { title, contentHtml } = postData

return (

<Layout title={title}>

<article className="post-content">




dangerouslySetInnerHTML={{ __html: contentHtml }}





// As in the list page, passes the captured information to the page propertiesexport const getStaticProps: GetStaticProps = async ({ params }) => {

// Collect data from the post "en/filename"

const postData = await getPostData(`/${params.lang}/${}`)

return {

props: {

locale: params?.lang || "pt", // Captures [lang] from URL




// Use getAllPostIds to inform which pages to generate when exporting static files.export const getStaticPaths: GetStaticPaths = async () => {

const paths = await getAllPostIds()

return {


fallback: false,


export default Post

This is enough for a simple blog page.

Wrapping it up

To write these two articles, I used the reference I left below. It was the closest one to what I wanted to achieve. However, there are certain things that were not so useful to me, or caused unwanted complexity for the size of the project. Note that there is no need for external libraries for the translations, which is quite interesting. If you have any questions or suggestions, leave a comment. I will be glad to get your feedback!

Below, I left a link to this project repository on GitHub, in case you want to see the complete source code.


If this article helped you in some way, consider donating. This will help me to create more content like this!


Created by

Elves Sousa







Related Articles