A blog is one of the best ways to learn a framework and build your online presence at the same time. Next.js makes blogs fast by default with static generation, and MDX lets you write content in Markdown while embedding React components when you need interactivity. This guide builds a fully functional blog with MDX content, syntax highlighting, an RSS feed, and SEO metadata.
Prerequisites
- →Node.js 20+
Required for Next.js and its build tooling.
- →Basic React Knowledge
Understanding of components, props, and JSX syntax.
- →Familiarity with Markdown
MDX extends Markdown, so knowing headings, links, lists, and code blocks is helpful.
Create the Next.js Project
Scaffold a new Next.js app with TypeScript and Tailwind CSS. The App Router with static generation is ideal for blogs because every page is pre-rendered at build time, giving you near-instant load times and excellent SEO.
npx create-next-app@latest my-blog \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir
cd my-blog
npm install gray-matter next-mdx-remote reading-timeTip: gray-matter parses YAML frontmatter from your MDX files, next-mdx-remote renders MDX on the server, and reading-time estimates article length.
Set Up the Content Directory with MDX Files
Create a content directory at the project root for your blog posts. Each MDX file includes YAML frontmatter with metadata like title, date, description, and tags. This file-based approach means you can write posts in any text editor and version them with Git.
---
title: "Getting Started with Next.js"
date: "2026-03-10"
description: "A beginner-friendly introduction to building web apps with Next.js."
tags: ["nextjs", "react", "tutorial"]
published: true
---
# Getting Started with Next.js
Next.js is a React framework that gives you **server-side rendering**,
**static generation**, and **API routes** out of the box.
## Why Next.js?
- File-based routing
- Built-in image optimization
- Zero-config TypeScript support
```tsx
export default function Home() {
return <h1>Hello, Next.js!</h1>;
}
```Tip: Use a 'published' boolean in frontmatter to draft posts without deploying them.
Tip: Keep filenames URL-friendly — they become your slugs.
Build the Content Utility Functions
Create helper functions that read MDX files from disk, parse their frontmatter, sort by date, and return typed post data. These utilities power both your blog index page and individual post pages. Using fs and path from Node.js works because these functions only run on the server during static generation.
// src/lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";
const postsDirectory = path.join(process.cwd(), "content/posts");
export interface Post {
slug: string;
title: string;
date: string;
description: string;
tags: string[];
published: boolean;
readingTime: string;
content: string;
}
export function getAllPosts(): Post[] {
const files = fs.readdirSync(postsDirectory);
return files
.filter((file) => file.endsWith(".mdx"))
.map((file) => {
const slug = file.replace(/\.mdx$/, "");
const raw = fs.readFileSync(path.join(postsDirectory, file), "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title,
date: data.date,
description: data.description,
tags: data.tags ?? [],
published: data.published ?? false,
readingTime: readingTime(content).text,
content,
};
})
.filter((post) => post.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getPostBySlug(slug: string): Post | undefined {
return getAllPosts().find((post) => post.slug === slug);
}Create the Blog Index Page
Build the main blog listing page as a server component that fetches all posts at build time. Each post card shows the title, date, reading time, description, and tags. Because this is a server component, there is zero client-side JavaScript — the page is pure HTML.
// src/app/blog/page.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
export const metadata = {
title: "Blog",
description: "Articles about web development, React, and Next.js.",
};
export default function BlogPage() {
const posts = getAllPosts();
return (
<main className="mx-auto max-w-2xl px-4 py-16">
<h1 className="mb-12 text-4xl font-bold">Blog</h1>
<div className="space-y-10">
{posts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group">
<time className="text-sm text-gray-500">{post.date}</time>
<span className="ml-2 text-sm text-gray-400">
{post.readingTime}
</span>
<h2 className="mt-1 text-xl font-semibold group-hover:text-blue-600">
{post.title}
</h2>
<p className="mt-2 text-gray-600">{post.description}</p>
</Link>
<div className="mt-3 flex gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600"
>
{tag}
</span>
))}
</div>
</article>
))}
</div>
</main>
);
}Create Dynamic Post Pages with MDX Rendering
Build the individual blog post page using Next.js dynamic routes and generateStaticParams for static generation. The page fetches the MDX content, renders it with next-mdx-remote, and generates proper SEO metadata. Each post gets its own statically generated HTML page at build time.
// src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export function generateMetadata({ params }: { params: { slug: string } }) {
const post = getPostBySlug(params.slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
publishedTime: post.date,
},
};
}
export default function PostPage({ params }: { params: { slug: string } }) {
const post = getPostBySlug(params.slug);
if (!post) notFound();
return (
<article className="mx-auto max-w-2xl px-4 py-16">
<header className="mb-8">
<time className="text-sm text-gray-500">{post.date}</time>
<span className="ml-2 text-sm text-gray-400">{post.readingTime}</span>
<h1 className="mt-2 text-4xl font-bold">{post.title}</h1>
<p className="mt-4 text-lg text-gray-600">{post.description}</p>
</header>
<div className="prose prose-lg max-w-none">
<MDXRemote source={post.content} />
</div>
</article>
);
}Tip: Install @tailwindcss/typography for the 'prose' class that beautifully styles rendered Markdown.
Tip: Add custom MDX components for callouts, code blocks with copy buttons, or embedded videos.
Add Syntax Highlighting with Rehype Pretty Code
Install rehype-pretty-code to add VS Code-quality syntax highlighting to your code blocks. It uses Shiki under the hood, which means your code is highlighted at build time with no client-side JavaScript. The result is beautiful, accessible code blocks that load instantly.
// next.config.ts
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
import rehypePrettyCode from "rehype-pretty-code";
const withMDX = createMDX({
options: {
rehypePlugins: [
[
rehypePrettyCode,
{
theme: "github-dark",
keepBackground: true,
},
],
],
},
});
const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "mdx"],
};
export default withMDX(nextConfig);Tip: Run 'npm install @next/mdx rehype-pretty-code shiki' to install the required packages.
Tip: Try different themes like 'one-dark-pro', 'dracula', or 'nord' to match your blog's design.
Generate an RSS Feed and Sitemap
Create an RSS feed so readers can subscribe to your blog, and a sitemap so search engines can discover all your posts. Next.js App Router supports route handlers that return XML responses. Both files are generated dynamically from your posts data.
// src/app/feed.xml/route.ts
import { getAllPosts } from "@/lib/posts";
export function GET() {
const posts = getAllPosts();
const siteUrl = "https://yourdomain.com";
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>My Blog</title>
<link>${siteUrl}</link>
<description>Articles about web development.</description>
<atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
${posts
.map(
(post) => `
<item>
<title>${post.title}</title>
<link>${siteUrl}/blog/${post.slug}</link>
<description>${post.description}</description>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<guid>${siteUrl}/blog/${post.slug}</guid>
</item>`
)
.join("")}
</channel>
</rss>`;
return new Response(rss, {
headers: { "Content-Type": "application/xml" },
});
}Tip: Add a <link rel='alternate' type='application/rss+xml'> tag in your root layout to advertise the feed.
Tip: Use Next.js built-in sitemap.ts for generating sitemaps with typed exports.
Next Steps
- →Add a newsletter subscription form with ConvertKit or Buttondown integration.
- →Implement full-text search using Pagefind or Algolia for client-side search.
- →Add view counts with Vercel KV or Upstash Redis to show popular posts.
- →Create category and tag archive pages for better content organization.