dois:pontos

Making a multilingual site with Next.js - Part 2

- 8 min

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 tutorial1 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.

img 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.

Links

Comments