How to Easily Integrate a Markdown Blog into Your Next.js App Directory
This tutorial is suitable for simpler project implementations. If you are a Next.js beginner and don’t want to use overly complex methods to build a blog, then this tutorial is perfect for you.
Next.js official documentation on Markdown explains how to render Markdown, specifically for the App directory. I tried it, but it didn’t work as expected. It might be a version issue. Regardless, I didn’t solve the problem and used a different approach to achieve the result.
This tutorial applies to Next.js projects using the App directory. The following example is for a project with a multilingual structure.
Implementation Concept
Let’s explain the general logic with the file structure:
Markdown files are managed under the /app/_articles/[lang]
folder. If you have a multilingual directory, each language is in a separate folder. If not, you can put them directly in the /app/_articles
folder.
Additionally, from the first line, you can include some Frontmatter in your Markdown files. It’s usually placed at the beginning of the file and separated by ---
symbols, providing additional information like publication time, update time, whether it’s published, and the corresponding description. You can customize these elements and use them for many personalized operations. I generally use them to fill in meta information.
Here are some examples of Frontmatter:
---
title: "This is the blog title"
createdAt: "2024-11-12"
updatedAt: "2024-11-12"
isPublished: true
description: "This is the blog description"
---
As you accumulate more files, you’ll need some code to manage and display your Markdown information. For instance:
- Display all Markdown blog posts on your blog page.
- Navigate to the corresponding blog details based on the Markdown file name. For example, accessing
https://i18ncode.com/blog/how-nextjs-app-simply-make-i18n
displays the text from thehow-nextjs-app-simply-make-i18n.mdx
file. - Render the Markdown text, including the corresponding page’s meta information.
Specific Code
The things to do are described above. Here is the corresponding code.
First, let’s package some common methods in the /lib/mdx.ts
file for later use:
// mdx.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";
const articlesDirectory = path.join(process.cwd(), "app/_articles");
const webContentDirectory = path.join(process.cwd(), "app/_contents");
// Get the raw MDX/MD data
export function getMdxRawData(fileName: string, lang: string, hasSuffix: boolean) {
let fullPath = path.join(articlesDirectory, lang, `${fileName}`);
let suffix = hasSuffix // Check if there is a suffix, if not, add one
? ""
: fs.existsSync(`${fullPath}.mdx`)
? ".mdx"
: ".md";
const fileContents = fs.readFileSync(`${fullPath}${suffix}`, "utf8");
return fileContents;
}
// Process the frontmatter in the raw MDX/MD data
export function getMdxFrontmatter(mdxRawData: string) {
const { content, data } = matter(mdxRawData);
return {
content,
frontmatter: data,
readingTime: readingTime(content).text, // Calculate reading time
};
}
// Get all information about an article
export function getArticlesData(fileName: string, lang: string, hasSuffix = false) {
return {
...getMdxFrontmatter(getMdxRawData(fileName, lang, hasSuffix)),
fileName: fileName.split(".").slice(0, -1).join("."), // Remove suffix
};
}
// Get all articles in the _articles directory
export function getAllArticlesData(lang: string) {
const fileNames = fs.readdirSync(articlesDirectory + "/" + lang);
const allArticlesData = fileNames.map((fileName) => {
return getArticlesData(fileName, lang, true);
});
return allArticlesData;
}
You can adjust the code above based on the specific requirements of your project.
Display all markdown blogs on your blog page
Call the getAllArticlesData method that has been encapsulated above. This method supports a parameter called lang, which is available in multilingual projects. If you pass in the value en, it will retrieve all markdown files from /app/_articles/en.
Then don’t forget to sort by time:
export default async function BlogPage({params: {lang}}: { params: { lang: Locale } }) {
const allArticlesData = getAllArticlesData(lang);
const dictionary = await getDictionary(lang);
const sortedArticles = allArticlesData.sort((a, b) => {
// Convert date string to date object
const dateA = new Date(a.frontmatter.createdAt).getTime();
const dateB = new Date(b.frontmatter.createdAt).getTime();
// Compare dates, return value determines sort order
return dateB - dateA; // Sort in descending order
});
return (
<div>
<div className="mb-16">
<h1 className={title()}>{dictionary.blog.title}</h1>
<div className="mt-8">
{sortedArticles.map(article => (
<Blog blog={article} key={article.fileName} lang={lang} />
))}
</div>
</div>
<CallToAction dictionary={dictionary} />
</div>
);
}
Navigate to the corresponding blog details based on the markdown file name
Use a simple jump in the Blog component:
<Link href={`/${lang}/blog/${blog.fileName}`} />
Pass the filename over, and the details page will find the corresponding file based on the filename and render it.
Render markdown text
In the /app/[lang]/blog/[id]/page.tsx page, the specific markdown is parsed and rendered, filling the page with the corresponding content and rendering meta information:
import { getArticlesData } from "@/lib/mdx";
import { Remarkable } from 'remarkable';
import hljs from 'highlight.js';
import {getDictionary} from "@/get-dictionaries";
import CallToAction from "@/components/cta";
import React from "react";
export const generateMetadata = async ({ params }: any) => {
const { content, frontmatter, readingTime } = getArticlesData(params.id, params.lang);
const lang = await getDictionary(params.lang);
return {
title: frontmatter.title + " | " + lang.blog.meta.title,
description: frontmatter.description,
openGraph: {
title: frontmatter.title + " | " + lang.blog.meta.title,
type: "website",
url: ``,
images: [
{
// This can also have width and height properties, see:https://medium.com/@moh.mir36/open-graph-with-next-js-v13-app-directory-22c0049e2087
url: "/logo.png",
alt: ""
}
],
siteName: "",
description: frontmatter.description,
locale: ""
},
twitter: {
images: [
{
url: "/logo.png",
alt: ""
}
],
title: frontmatter.title + " | " + lang.blog.meta.title,
description: frontmatter.description,
card: "summary_large_image"
},
}
}
// !important: You need to add the plugin: require("@tailwindcss/typography") in tailwind.config.js to style the blog, check the corresponding code yourself.
const Page = async ({ params }: any) => {
const { content, frontmatter, readingTime } = getArticlesData(params.id, params.lang);
const md = new Remarkable({
html: true,
breaks: true,
linkify: true,
typographer: true,
highlight: function (str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, str).value;
} catch (err) {}
}
try {
return hljs.highlightAuto(str).value;
} catch (err) {
}
return ''; // use external default escaping
}
});
const blog = md.render(content, frontmatter);
const dictionary = await getDictionary(params.lang);
return (
<main className="container pb-24 text-start">
<div
className="prose dark:prose-invert prose-headings:mt-8 prose-headings:font-semibold prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg dark:prose-headings:text-white w-screen p-4">
<div dangerouslySetInnerHTML={{__html: blog}} className="prose-pre:p-4 dark:prose-pre:bg-gray-800 w-full p-4"/>
</div>
<CallToAction dictionary={dictionary} />
</main>
);
};
export default Page;
We’ve used the Remarkable solution as a replacement for Next’s MDXRemote component.
We’re almost halfway there, but we might need to make some style adjustments. We’ll need to add the require("@tailwindcss/typography")
plugin to the tailwind.config.js
file. Here’s the code:
import {nextui} from '@nextui-org/theme'
/** @type {import('tailwindcss').Config} */
module.exports = {
//...
plugins: [
// ....
require("@tailwindcss/typography"), // markdown typography
],
}
That’s it! We’re good to go. Of course, you’ll need to install any missing dependencies based on your project requirements.
Managing and Translating Multilingual Markdown Files
As you can see, with this approach, if you have a multilingual site, you’ll inevitably need to translate and manage the corresponding markdown files.
Using GPT for translation has length limitations. It works well for the first language but starts forgetting the original text after that, resulting in gibberish. Either you repeat the original text with every GPT request, which limits the conversation to a few rounds, or you start a new conversation.
When I first started doing this, it took me an entire afternoon to complete a single blog post. It was simply too time-consuming.
Machine translation is even less acceptable. It can’t recognize markdown symbols, leading to formatting errors. Additionally, machine translations are often stilted.
Taking these considerations into account, I created a dedicated translator for this specific scenario. If you’re interested, you can check out the markdown translator.
The markdown translator considers length issues by splitting the text and sending requests in segments. You can throw an entire markdown file into it for translation and get the final result directly. After extensive testing, I found this to be reliable. It also recognizes and preserves markdown formatting, so you don’t have to worry about losing it. Finally, it considers localization, so the same text is expressed in a more localized way by the AI. It should be quite useful for those working on internationalization projects.