Making a Multilingual Site With Next.JS — Part 2
This article was originally posted here.
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"
---## LoremLorem 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-matternpm 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 files
const postsDirectory = path.resolve(process.cwd(), "posts")// Returns a list of files in the directories and
// subdirectories in the formal ['en/filename.md']
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 date
export 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 = 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
}),
}
}) // Sorts collected information by date
return allPostsData.sort((a, b) => {
if (a.date < b.date) {
return 1
} else {
return -1
}
})
}// Separates the file name and language
export function getAllPostIds() {
// Get the list of * .md files in the posts directory
const fileNames = getAllPostFileNames(postsDirectory) // Splits the "en" and "filename" parts of ['en/filename.md']
// and return them as parameters for later use in Next
return fileNames.map((fileName) => ({
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 {
id,
...(frontMatter.data as { date: string; title: string }),
contentHtml,
}
}
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 site.com/language/post/article
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 way 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: site.com/[lang]/articles
. This [lang]
will be replaced by pt
oren
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 date
import { 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">
<h1>{t("articles")}</h1>
{/* List of articles */}
{pagedPosts.map((post) => (
<article key={post.id} className="post">
<Link href={`/[lang]/post/[id]`} as={`/${locale}/post/${post.id}`}>
<a>
<h3>{post.title}</h3>
</a>
</Link>
<time>
{new Date(post.date).toLocaleDateString(locale, dateOptions)}
</time>
{post.description && <p>{post.description}</p>}
</article>
))} {/* Paging */}
{numPages > 1 && (
<div className="pagination">
{Array.from({ length: numPages }, (_, i) => (
<button
key={`pagination-number${i + 1}`}
onClick={() => setCurrentPage(i + 1)}
className={currentPage === i + 1 ? "active" : ""}
>
{i + 1}
</button>
))}
</div>
)}
</section>
</Layout>
)
}// Captures the information needed for the static page
export 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
allPostsData,
},
}
}// Generates static files on export
export 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">
<h1>{title}</h1>
<div
className="post-text"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</article>
</Layout>
)
}// As in the list page, passes the captured information to the page properties
export const getStaticProps: GetStaticProps = async ({ params }) => {
// Collect data from the post "en/filename"
const postData = await getPostData(`/${params.lang}/${params.id}`) return {
props: {
locale: params?.lang || "pt", // Captures [lang] from URL
postData,
},
}
}// Use getAllPostIds to inform which pages to generate when exporting static files.
export const getStaticPaths: GetStaticPaths = async () => {
const paths = await getAllPostIds() return {
paths,
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 grad 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.