How to Add a Blog to Your Next.js Site with MDX (and Keep It Alive)
Add an MDX blog to a Next.js App Router site: posts as files with frontmatter, static pages, metadata, sitemap, and structured data. Tested on Next.js 16. Plus how to keep publishing after week one.

Adding a blog to a Next.js site takes an afternoon.
Keeping it alive takes a habit.
This guide covers both. First, a small, boring setup that will not need a rewrite in six months: posts as MDX files in your repo, static pages, proper metadata, and a sitemap. Then, what actually stops most product blogs at four posts, and how to get past it.
Pick the setup first
Next.js has two common ways to do MDX.
@next/mdx turns .mdx files into pages or importable components. It is the official route and works well. It does not read YAML frontmatter out of the box, so titles and dates usually go in an exported metadata object, or you add remark plugins for frontmatter.
next-mdx-remote + gray-matter treats each post as a plain file with YAML frontmatter at the top. You read the file, split the frontmatter from the body, and render the body as MDX on the server.
This guide uses the second one, for a practical reason: YAML frontmatter is what most editors, CMS exports, and AI writing tools produce. Your posts stay portable files that any tool can read and write.
1. Install
npm install next-mdx-remote gray-matter remark-gfm
remark-gfm adds GitHub-style tables, task lists, and strikethrough. Skip it if you don't need them.
2. Write your first post
Create content/blog/hello-world.mdx:
---
title: "Hello, world"
description: "The first post on our new blog."
date: "2026-11-15"
---
This is the first post. It is plain **Markdown**, with one custom component:
<Callout>Components work too, as long as you pass them in.</Callout>
| Feature | Works |
| --- | --- |
| Tables | Yes, with remark-gfm |
The file name is the URL slug. This one becomes /blog/hello-world.
3. Read the posts
Create lib/posts.ts. It lists the files, splits frontmatter from content, and returns posts newest first.
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
const POSTS_DIR = path.join(process.cwd(), "content/blog");
export type PostMeta = {
slug: string;
title: string;
description: string;
date: string;
};
function readPost(slug: string) {
const raw = fs.readFileSync(path.join(POSTS_DIR, `${slug}.mdx`), "utf8");
const { data, content } = matter(raw);
const meta: PostMeta = {
slug,
title: String(data.title),
description: String(data.description ?? ""),
date: String(data.date),
};
return { meta, content };
}
export function getAllPosts(): PostMeta[] {
return fs
.readdirSync(POSTS_DIR)
.filter((file) => file.endsWith(".mdx"))
.map((file) => readPost(file.replace(/\.mdx$/, "")).meta)
.sort((a, b) => b.date.localeCompare(a.date));
}
export function getPost(slug: string) {
if (!fs.existsSync(path.join(POSTS_DIR, `${slug}.mdx`))) return null;
return readPost(slug);
}
This runs only on the server, at build time, so using fs is fine.
4. Add your components
MDX can render React components, but only the ones you pass in. Create components/mdx.tsx:
export function Callout({ children }: { children: React.ReactNode }) {
return (
<aside style={{ borderLeft: "4px solid #e8a33d", padding: "0.75rem 1rem" }}>
{children}
</aside>
);
}
export const mdxComponents = { Callout };
Keep this list short and stable. Every component you add here is one more thing every future post, and every tool that writes posts for you, has to get exactly right, or the build fails.
5. Set a base URL
Canonical and Open Graph URLs need an absolute base. In app/layout.tsx, add metadataBase:
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Use your real domain. Relative URLs like /blog/hello-world in metadata are resolved against it.
6. The blog index
Create app/blog/page.tsx:
import type { Metadata } from "next";
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
export const metadata: Metadata = {
title: "Blog",
description: "Notes from the team.",
alternates: { canonical: "/blog" },
};
export default function BlogIndex() {
const posts = getAllPosts();
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
<p>{post.description}</p>
</li>
))}
</ul>
</main>
);
}
7. The post page
Create app/blog/[slug]/page.tsx. This is where most of the work happens:
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { mdxComponents } from "@/components/mdx";
import { getAllPosts, getPost } from "@/lib/posts";
type Props = { params: Promise<{ slug: string }> };
// Only the posts that exist at build time; anything else is a 404.
export const dynamicParams = false;
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
return {
title: post.meta.title,
description: post.meta.description,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: "article",
title: post.meta.title,
description: post.meta.description,
url: `/blog/${slug}`,
publishedTime: post.meta.date,
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.meta.title,
description: post.meta.description,
datePublished: post.meta.date,
};
return (
<main>
<article>
<h1>{post.meta.title}</h1>
<time dateTime={post.meta.date}>{post.meta.date}</time>
<MDXRemote
source={post.content}
components={mdxComponents}
options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}
/>
</article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
</main>
);
}
What each piece does:
paramsis a Promise in current Next.js, so youawaitit in both the page andgenerateMetadata.generateStaticParamslists every post, so each one is prerendered as static HTML at build time.dynamicParams = falseturns any slug that isn't a file into a 404, instead of trying to render it.generateMetadatagives each post its own title, description, canonical URL, and Open Graph tags.- The JSON-LD script tells search engines this page is a blog post. The
.replacestops a<in your text from breaking out of the script tag.
8. Add a sitemap
Create app/sitemap.ts so search engines find new posts without waiting for a link:
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";
const SITE_URL = "https://example.com";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: `${SITE_URL}/blog` },
...getAllPosts().map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: post.date,
})),
];
}
If your site already has a sitemap, add the post entries to it instead of creating a second one.
9. Build and check
Run next build. You should see your post listed as prerendered:
Route (app)
├ ○ /blog
├ /blog/[slug]
│ └ ● /blog/hello-world
└ ○ /sitemap.xml
Then run next start and check four things:
- /blog/hello-world shows the post, the Callout, and the table.
- View source shows a canonical link and og:url using your real domain.
- /sitemap.xml lists the blog and every post.
- /blog/anything-else returns 404.
That is the whole blog. Styling is up to you: the Tailwind typography plugin is a quick way to make the article body readable.
Where product blogs actually die
The setup above takes an afternoon. Most product blogs still stop at four posts.
It is rarely the code. It is that every post competes with shipping the product, and the product always wins. The drafts folder fills up. The last post is from March.
A few things make it more likely you keep going:
- Write for searches people actually make. Pick each topic from a real query, either one you already show up for in Search Console, or one autocomplete suggests. A post nobody searches for is hard to justify writing next month.
- Keep the publishing path short. Write the post in a branch, open a pull request, read the preview deploy, merge. No CMS, no copy-paste.
- Update before you add. Once you have a dozen posts, the best next move is often fixing one that is slipping. Our guide to finding and fixing content decay shows how to spot them.
- Get help with the writing, not the judgment. An AI tool can draft. You still decide what is true and what you would actually recommend.
If you want that help to land in this exact setup, as MDX files with frontmatter committed to your repo, NichePressa does that: it writes in your voice, plans topics from your Search Console data, and commits to a branch you merge. For the wider field, see our comparison of AI SEO tools for SaaS founders.
Frequently asked questions
Should I use @next/mdx or next-mdx-remote for a Next.js blog?
Use @next/mdx if you are happy writing each post as a page or importable component and keeping metadata in an exported object. Use next-mdx-remote with gray-matter if you want posts as files with YAML frontmatter, which is what most editors and writing tools produce.
How do I add frontmatter to MDX in Next.js?
Put YAML between --- lines at the top of each file, then read it with gray-matter, which returns the frontmatter as data and the body as content. @next/mdx does not read frontmatter by default. Its docs suggest export const metadata or a remark plugin instead.
Will an MDX blog be static in the App Router?
Yes, if you list every slug in generateStaticParams and read files at build time. next build will mark each post as prerendered. Set dynamicParams = false so unknown slugs return 404.
How do I add a sitemap for blog posts in Next.js?
Create app/sitemap.ts that returns an array of URLs, including one entry per post from the same function that lists your posts. Next.js serves it at /sitemap.xml.
Why does my frontmatter date sort wrong?
An unquoted YAML date such as date: 2026-11-15 is parsed into a JavaScript Date object, not a string. Quote it, date: "2026-11-15", or convert it explicitly when you read the post.
NichePressa writes MDX posts in your voice and commits them to your Next.js repo. Create two articles free.


