Rendering Markdown in Next.js 16 with gray-matter and remark
I recently moved our company blog from a headless CMS to plain .md files stored directly in the repository alongside the application code.
The CMS was costing us $89/month to manage what was, at the end of the day, a collection of text files. The migration took an afternoon, and the result is a simple Markdown-based blog that works nicely with Next.js 16.
Most tutorials I found were written for the Pages Router or older versions of Next.js, so I wanted to document the setup I ended up using with the App Router.
The stack is intentionally small:
- Next.js 16
- TypeScript
- gray-matter
- remark
- remark-rehype
- rehype-stringify
Everything lives in the repository, posts are version-controlled with Git, and the App Router handles the pages.
The two jobs: parse frontmatter and render Markdown
A Markdown-based blog has two basic jobs:
Read the frontmatter from the Markdown file.
Convert the Markdown body into HTML.
gray-matter handles the first part, while the remark ecosystem handles the second.
Install the required packages:
npm install gray-matter remark remark-rehype rehype-stringify
If Markdown can ever come from users or another untrusted source, also install:
npm install rehype-sanitize
I prefer the remark-rehype + rehype-stringify pipeline over remark-html because it fits naturally into the broader unified/rehype ecosystem. It also makes it easier to add plugins later for things such as syntax highlighting, sanitization, links, or other HTML transformations.
Project structure
I keep Markdown content outside the public directory:
my-blog/
├── app/
│ └── blog/
│ └── [slug]/
│ └── page.tsx
├── content/
│ └── posts/
│ ├── hello-world.md
│ └── nextjs-markdown.md
├── lib/
│ └── markdown.ts
├── next.config.ts
└── package.json
The important part is that content/posts is not inside public.
Anything inside public is intended to be publicly served as a static asset. Your source Markdown files usually shouldn't be there.
lib/markdown.ts
The Markdown utility is responsible for finding posts, reading a file, parsing its frontmatter, and converting its Markdown body into HTML.
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { remark } from "remark";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
const postsDirectory = path.join(
process.cwd(),
"content/posts"
);
export interface Post {
slug: string;
title: string;
date: string;
description: string;
contentHtml: string;
}
function formatDate(value: unknown): string {
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value;
}
return "";
}
export function getPostSlugs(): string[] {
if (!fs.existsSync(postsDirectory)) {
return [];
}
return fs
.readdirSync(postsDirectory)
.filter((file) => file.endsWith(".md"))
.map((file) => file.replace(/\.md$/, ""));
}
export async function getPost(slug: string): Promise<Post> {
const filePath = path.join(
postsDirectory,
`${slug}.md`
);
if (!fs.existsSync(filePath)) {
throw new Error(`Post not found: ${slug}`);
}
const raw = fs.readFileSync(filePath, "utf8");
const { content, data } = matter(raw);
const processed = await remark()
.use(remarkRehype)
.use(rehypeStringify)
.process(content);
return {
slug,
title:
typeof data.title === "string"
? data.title
: slug,
date: formatDate(data.date),
description:
typeof data.description === "string"
? data.description
: "",
contentHtml: processed.toString(),
};
}
There are a few details here that are easy to miss.
gray-matter can turn dates into Date objects
Consider this frontmatter:
---
title: "My First Post"
date: 2026-08-26
---
Depending on how the YAML is parsed, date can be returned as a JavaScript Date object rather than a string.
That's why I normalize it before returning the post data.
Without that normalization, you can eventually run into problems when rendering frontmatter values in React.
You can also avoid the ambiguity by keeping dates explicitly quoted:
date: "2026-08-26"
I still prefer normalizing the value in the data layer because it keeps the rest of the application predictable.
Creating a Markdown post
A post can now look like this:
---
title: "Rendering Markdown in Next.js 16"
date: "2026-08-26"
description: "A simple Markdown pipeline for Next.js 16."
---
# Rendering Markdown in Next.js 16
This post explains how to render Markdown
files using Next.js and the remark ecosystem.
## Why Markdown?
Markdown files are simple, portable,
version-controlled, and easy to edit.
```tsx
const message = "Hello from Markdown";
The content before the first `---` is frontmatter. Everything after it is the Markdown body.
---
## The page: App Router
With the App Router, the blog page can be an async Server Component.
There is no `getStaticProps`, no client-side request, and no need to fetch the Markdown over HTTP.
Create:
```text
app/blog/[slug]/page.tsx
Then:
import { notFound } from "next/navigation";
import {
getPost,
getPostSlugs,
} from "@/lib/markdown";
interface PostPageProps {
params: Promise<{
slug: string;
}>;
}
export function generateStaticParams() {
return getPostSlugs().map((slug) => ({
slug,
}));
}
export default async function PostPage({
params,
}: PostPageProps) {
const { slug } = await params;
let post;
try {
post = await getPost(slug);
} catch {
notFound();
}
return (
<article>
<header>
<h1>{post.title}</h1>
{post.date && (
<time dateTime={post.date}>
{post.date}
</time>
)}
{post.description && (
<p>{post.description}</p>
)}
</header>
<div
dangerouslySetInnerHTML={{
__html: post.contentHtml,
}}
/>
</article>
);
}
One important Next.js detail is the params type:
params: Promise<{ slug: string }>
and then:
const { slug } = await params;
This is different from the older App Router examples that commonly showed params as a plain object.
Generating static pages
This function:
export function generateStaticParams() {
return getPostSlugs().map((slug) => ({
slug,
}));
}
returns one route parameter for every Markdown file.
For example, if the directory contains:
content/posts/
├── hello-world.md
├── nextjs-markdown.md
└── typescript-tips.md
generateStaticParams() produces:
[
{ slug: "hello-world" },
{ slug: "nextjs-markdown" },
{ slug: "typescript-tips" },
]
Those parameters allow Next.js to generate the corresponding blog routes.
The resulting URLs would be:
/blog/hello-world
/blog/nextjs-markdown
/blog/typescript-tips
This is one of the main reasons I like the Markdown approach for small blogs: there is no separate content API involved.
Adding metadata
For a real blog, I also want each post to have its own title and description.
The App Router makes this straightforward with generateMetadata().
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import {
getPost,
getPostSlugs,
} from "@/lib/markdown";
interface PostPageProps {
params: Promise<{
slug: string;
}>;
}
export function generateStaticParams() {
return getPostSlugs().map((slug) => ({
slug,
}));
}
export async function generateMetadata({
params,
}: PostPageProps): Promise<Metadata> {
const { slug } = await params;
try {
const post = await getPost(slug);
return {
title: post.title,
description: post.description,
};
} catch {
return {
title: "Post Not Found",
};
}
}
export default async function PostPage({
params,
}: PostPageProps) {
const { slug } = await params;
let post;
try {
post = await getPost(slug);
} catch {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
{post.date && (
<time dateTime={post.date}>
{post.date}
</time>
)}
<div
dangerouslySetInnerHTML={{
__html: post.contentHtml,
}}
/>
</article>
);
}
You can extend the metadata later with Open Graph images, authors, canonical URLs, and other SEO information.
What about dangerouslySetInnerHTML?
At first glance, this line looks concerning:
<div
dangerouslySetInnerHTML={{
__html: post.contentHtml,
}}
/>
And it should make you stop and think.
The important question isn't whether dangerouslySetInnerHTML is being used. The important question is where the HTML came from.
In this setup, the Markdown files are part of the application's source code. If only trusted developers can modify those files, the content is effectively trusted application input.
But that assumption changes immediately if users can submit Markdown.
For user-generated Markdown, sanitize the generated HTML:
npm install rehype-sanitize
Then add it to the pipeline:
import rehypeSanitize from "rehype-sanitize";
const processed = await remark()
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(content);
The important rule is simple:
Trusted repository content and untrusted user content should not be treated the same way.
What about syntax highlighting?
One reason I prefer the remark-rehype pipeline is that it leaves room for additional plugins.
For example, syntax highlighting can be added later using a rehype-compatible solution.
The basic pipeline:
Markdown
↓
remark
↓
remark-rehype
↓
rehype plugins
↓
rehype-stringify
↓
HTML
This is much more flexible than treating Markdown-to-HTML conversion as one isolated operation.
You can add functionality such as:
Syntax highlighting
HTML sanitization
Custom links
Custom HTML transformations
Additional Markdown syntax
without redesigning the entire content layer.
Why I don't put Markdown in public
I made this mistake during the migration.
My first thought was:
"If the Markdown files are in
public, I can also serve the raw files."
Next.js will happily treat those files as public assets.
That's exactly what I didn't want.
Anything placed inside public can be requested directly by its URL. That means drafts, internal notes, or other Markdown files can accidentally become part of the deployed application.
Keep your source content somewhere like:
content/posts/
instead.
Read those files from the server and render them through your application.
How fast is it?
For a normal blog, Markdown processing is extremely cheap compared with most other parts of a modern web build.
A typical post with a few thousand words can be parsed and converted to HTML in milliseconds on a modern machine.
So if you have a few dozen Markdown posts, the Markdown conversion itself is unlikely to be the reason your CI build is slow.
If a build is taking minutes, I'd look at things such as:
Image processing
Large dependency graphs
External API requests
Database operations
Large static datasets
Expensive build-time computations
before worrying about a few milliseconds of Markdown parsing.
What I'd do differently next time
The Markdown approach works extremely well when the people writing content are developers or when the content doesn't change constantly.
It's simple.
There is no CMS database.
There is no content API.
There is no authentication system just to write a blog post.
And every change is version-controlled with Git.
But there is a trade-off.
Eventually, a content team may want:
A WYSIWYG editor
Draft management
Scheduled publishing
Media management
Content previews
Role-based permissions
Non-technical editing
At that point, a Git-based Markdown workflow may stop being the right tool.
That's when I'd start considering a Git-friendly or headless CMS solution such as TinaCMS or Keystatic.
For a solo developer blog, documentation site, portfolio, or small company blog, though, a directory full of Markdown files is hard to beat.
The complete flow
The whole setup is surprisingly small:
.md file
│
├── Frontmatter
│ ├── title
│ ├── date
│ └── description
│
└── Markdown body
│
▼
gray-matter
│
▼
Markdown content
│
▼
remark
│
▼
remark-rehype
│
▼
rehype plugins
│
▼
rehype-stringify
│
▼
HTML
│
▼
Next.js Server Component
│
▼
Static blog page
That's essentially the entire content pipeline.
For a small blog, I don't think you need much more than this.
Markdown stays in Git, the application owns the rendering, and Next.js handles the routes.
Simple, fast, and easy to maintain.