Logo
Published on
ยท15 min read

Next.js - Creating a Multilingual (i18n) Blog Part 2

I apologize in advance for any awkward expressions in English. ๐Ÿ™

English is not my native language, and I have relied on ChatGPT's assistance to proceed with the translation.

Overview

In the Next.js - Creating a Multilingual (i18n) Blog Part 1 post, we handled multilingual support for the site, excluding blog posts.

For blog posts, we read them from .md(x) files and display them. It's not feasible to manage them using {t('key')} as discussed earlier.

We need to display registered posts according to the selected language and show post lists, tag lists, etc., differently.

As the content has grown longer, we'll split it into Parts 1 through 4.

In Part 2, we'll explore how to dynamically display blog posts in the selected language.


I created this project with reference to gitHub, i18n-tailwind-nextjs-starter-blog.

If you've decided to use the Tailwind CSS Blog Starter template and are starting your project from scratch, it might be a good idea to start with the above project.

In my case, since the project was already in progress, I proceeded with it by referring to the mentioned project.

Distinguishing Languages by File Names

  • For the default language (ko), you can keep the file names the same as before.
    (e.g., nextjs-blog-multi-language-part2.mdx)
  • For other languages (en), append .{locale} to the default language file name.
    (e.g., nextjs-blog-multi-language-part2.en.mdx)

Modifying Common Functions

Modify the common functions used to fetch the post list or retrieve post content based on the slug.

lib/mdx.js

  • Add filterLocaleFiles
    • Filter the file list to only include files that match the selected language.
    • If it's the default locale, filter files that don't have .{locale} appended to their names.
// Filtering Files by Language
function filterLocaleFiles(files, locale, defaultLocale, locales) {
  let filterFiles = files

  if (locales && defaultLocale && locales) {
    filterFiles =
      locale !== defaultLocale
        ? files.filter((path) => path.includes(`.${locale}.`))
        : files.filter((path) => !locales.some((locale) => path.includes(`.${locale}.`)))
  }

  return filterFiles
}
  • Modify getFiles
    • Use the newly added filterLocaleFiles to filter files that match the current language.
export function getFiles(type, locale, defaultLocale, locales) { // Add locale, defaultLocale, locales
  const prefixPaths = path.join(root, 'data', type)
  const files = getAllFilesRecursively(prefixPaths)

  const filterFiles = filterLocaleFiles(files, locale, defaultLocale, locales) // Add

  return filterFiles.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/')) // Change files -> filterFiles
}
  • Modify formatSlug
    • Remove the locale (e.g., .en) from the slug (file name).
export function formatSlug(slug, locales = null) {
  let result = slug.replace(/\.(mdx|md)/, '')

  // Added: Removing the locale from the Slug
  if (locales && locales.length > 0) {
    locales.forEach((locale) => {
      result = result.replace(`.${locale}`, '')
    })
  }

  return result
}

  • Modify getFileBySlug
    • To be able to read files that match the selected language, add the locale to the slug.

export async function getFileBySlug(type, slug, locale = null, defaultLocale = null) { // Add locale = null, defaultLocale = null
  // Modify it to fetch the files for the selected language if it's not the default locale.
  const mdxPath =
    locale === defaultLocale
      ? path.join(root, 'data', type, `${slug}.mdx`)
      : path.join(root, 'data', type, `${slug}.${locale}.mdx`)
  const mdPath =
    locale === defaultLocale
      ? path.join(root, 'data', type, `${slug}.md`)
      : path.join(root, 'data', type, `${slug}.${locale}.md`)
  const source = fs.existsSync(mdxPath)
    ? fs.readFileSync(mdxPath, 'utf8')
    : fs.readFileSync(mdPath, 'utf8')

  // No other changes are made.
}
  • Modify getAllFilesFrontMatter
    • Use filterLocaleFiles to filter files that match the current language.
export async function getAllFilesFrontMatter(folder, locale, defaultLocale, locales) { // Add locale, defaultLocale, locales
  const prefixPaths = path.join(root, 'data', folder)
  const files = getAllFilesRecursively(prefixPaths)

  const filterFiles = filterLocaleFiles(files, locale, defaultLocale, locales) // Add

  const allFrontMatter = []

  filterFiles.forEach((file) => { // Change filterFiles
    // ...
    if (frontmatter.draft !== true) {
      allFrontMatter.push({
        ...frontmatter,
        slug: formatSlug(fileName, locales), // Add locales to formatSlug
        date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
      })
    }
  })

  return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
}

lib/tags.js

  • Modify getAllTags
    • Add parameters for locale, defaultLocale, and locales to ensure that filtering is applied.
export async function getAllTags(type, locale, defaultLocale, locales) { // Add locale, defaultLocale, locales
  const files = await getFiles(type, locale, defaultLocale, locales) // Add locale, defaultLocale, locales

  // ...
}

Modifying Files in the pages Folder

pages/index.js

  • Add parameters for locale, defaultLocale, and locales to ensure filtering is applied.
export async function getStaticProps({ locale, defaultLocale, locales }) { // Add locale, defaultLocale, locales
  const posts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // Add locale, defaultLocale, locales

  return { props: { posts } }
}

You can see that the main page list is filtered by language.

pages/blog.js

  • Add parameters for locale, defaultLocale, and locales to ensure filtering is applied.
export async function getStaticProps({ locale, defaultLocale, locales }) { // Add defaultLocale, locales
  const posts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // Add locale, defaultLocale, locales

  // ...
}

You can confirm that the entire post list is filtered by language.

pages/blog/page/[page].js

  • For dynamic routing, modify the getStaticPaths part to generate paths for all languages. (If there are no paths, it will result in a 404 error.)
import { getAllFilesFrontMatter, getFiles } from '@/lib/mdx' // Add getFiles

export async function getStaticPaths({ defaultLocale, locales }) { // Add defaultLocale, locales
  // Generate paths for all languages
  const localePaths = (
    await Promise.all(
      locales.map(async (locale) => {
        // Change getAllFilesFrontMatter -> getFiles, Add locale, defaultLocale, locales
        const posts = await getFiles('blog', locale, defaultLocale, locales)
        const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE)
        return Array.from({ length: totalPages }, (_, i) => ({
          params: { page: (i + 1).toString() },
          locale,
        }))
      })
    )
  ).flat()

  // Modify paths
  return {
    paths: localePaths.map(({ params, locale }) => ({
      params,
      locale,
    })),
    fallback: false,
  }
}

export async function getStaticProps(context) {
  // Add locale, defaultLocale, locales
  const {
    params: { page },
    locale,
    defaultLocale,
    locales,
  } = context
  const posts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // Add locale, defaultLocale, locales
  // ...
}

You can confirm that pagination is generated for each language.

pages/tags.js

  • Add parameters for locale, defaultLocale, and locales to ensure filtering is applied.
export async function getStaticProps({ locale, defaultLocale, locales }) { // Add locale, defaultLocale, locales
  const tags = await getAllTags('blog', locale, defaultLocale, locales) // Add locale, defaultLocale, locales

  return { props: { tags } }
}

You can confirm that tags are generated for each language.

pages/tags/[tag].js

  • For dynamic routing, modify the getStaticPaths part to generate paths for all languages.
export async function getStaticPaths({ locales, defaultLocale }) { // Add locales, defaultLocale
  // Generate paths for all languages
  const localeTags = (
    await Promise.all(
      locales.map(async (locale) => {
        const tags = await getAllTags('blog', locale, defaultLocale, locales)
        return Object.keys(tags).map((tag) => ({
          params: { tag },
          locale,
        }))
      })
    )
  ).flat()

  // Modify paths
  return {
    paths: localeTags.map(({ params, locale }) => ({
      params,
      locale,
    })),
    fallback: false,
  }
}

export async function getStaticProps({ params, locale, defaultLocale, locales }) { // Add locale, defaultLocale, locales
  const allPosts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // Add locale, defaultLocale, locales
  // ...
}

You can confirm that when selecting a tag, it is generated in each language.

pages/blog/[...slug].js

  • For dynamic routing, make sure to modify the getStaticPaths part to generate paths for all languages.
  • Don't forget to add the locales parameter when calling formatSlug.
export async function getStaticPaths({ locales, defaultLocale }) { // Add locales, defaultLocale
  // Generate paths for all languages
  const localePosts = (
    await Promise.all(
      locales.map(async (locale) => {
        const posts = getFiles('blog', locale, defaultLocale, locales)
        return posts.map((post) => ({
          params: {
            slug: formatSlug(post, locales).split('/'), // Add locales
          },
          locale,
        }))
      })
    )
  ).flat()

  // Modify paths
  return {
    paths: localePosts.map(({ params, locale }) => ({
      params,
      locale,
    })),
    fallback: false,
  }
}

export async function getStaticProps({ params, locale, defaultLocale, locales }) { // Add locale, defaultLocale, locales
  const allPosts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // Add locale, defaultLocale, locales
  const postIndex = allPosts.findIndex(
    (post) => formatSlug(post.slug, locales) === params.slug.join('/') // Add locales
  )
  // ...
  const post = await getFileBySlug('blog', params.slug.join('/'), locale, defaultLocale) // Add locale, defaultLocale

  // ...
}

You can confirm that posts are displayed in the selected language. (If there are no posts written in the selected language, a 404 error occurs.)

Speed Issue on localhost

It's running slowly when checking on localhost. (I haven't tested it in production yet.)

The cause of this can be found in the Next.js official documentation: getStaticPaths.

Here's an excerpt from the documentation:

Runs on every request in development

In development (next dev), getStaticPaths will be called on every request.

Summary

I've confirmed that visible parts like the list and post viewing are working correctly. (There might still be some missing or erroneous parts. ๐Ÿ˜ฅ)

Now, what's left is adding the invisible parts such as SEO, Sitemap, RSS, etc.

In Next.js - Creating a Multilingual (i18n) Blog Part 3, we'll explore how to make SiteMap and RSS multilingual.

Code

The updated code can be found in the commit history.