DEV Community

Cover image for Making a multilingual site with Next.js - Part 2
Elves Sousa
Elves Sousa

Posted on • Updated on

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"
---

## Lorem

Lorem ipsum dolor sit amet consectetuer adispiscing elit.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
npm install --save remark remark-html gray-matter
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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


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


  1. Next.js tutorial on markdown post lists 

Discussion (5)

Collapse
tachuong20121997 profile image
Chuong Tran • Edited on

hi Elves Sousa, i have 2 links like this and both lead to 1 page, what should I do?
path 1: '/:language/:type/:name' ("vi/doctor/balestra")
path 2: '/:type/:name' ("doctor/balestra")
If not, the language will default to "en", please help me, thank you!
i tried using rewrite, but nextjs doesn't accept
dev-to-uploads.s3.amazonaws.com/up...

Collapse
elvessousa profile image
Elves Sousa Author

Hello, Chuong!
I have not worked with rewrites yet, but from what I could understand reading the docs on the subject, you don't need to repeat the parameters in the destination property. So, using the image you posted as a basis, try doing this on your next.config.js:

...
async rewrites() {
    return [
        {
            source: '/:type/:name',
            destination: '/profile/:language'
        },
        {
            source: '/:language/:type/:name',
            destination: '/profile'
        },
        {
            source: '/:language/:type/:name/:clinicId',
            destination: '/profile'
        },
        ...
    ]
},
...
...
Enter fullscreen mode Exit fullscreen mode

See if it works!

Collapse
sodhisaab profile image
Prince Sodhi

Is it possible to translate slugs as well? like following?

example.com/en/contact
example.com/de/kontakt

Collapse
elvessousa profile image
Elves Sousa Author

Hello, Prince! You can do it in two ways:

  • Manually: Create a page named "kontakt" in the pages folder and add a link for it in the navigation (add a string for translating the term "contact" to "kontakt"). I know it is not fancy, but hey, if your site is not that big, it will do the trick.
  • Programmatically: If your site is getting big, or with a lot of languages, the best way is creating these pages dynamically, in the same fashion posts were made. Create your pages content in Markdown in the target languages and add a new dynamic route for the pages, for them to act as a template. Using the repository used in this article as an example, to achieve your target slugs above, the dynamic route should be like: /pages/[lang]/[page].tsx.

I'll create a third part of this article, showing this stuff. But the logic is the one I explained if you wish to try by yourself.

Take care!

Collapse
elvessousa profile image
Elves Sousa Author

I just wrote a new article on that.
I hope you like it!