A Markdown-based blog is fast, version-controlled, and easy to maintain. No CMS, no database — just Markdown files in your repository. Here is how to set one up with Next.js App Router.
Approach Options
| Approach | Best For | Complexity |
|---|---|---|
| velite | Type-safe content, validation | Low |
| next-mdx-remote | Dynamic MDX from any source | Medium |
| @next/mdx | Simple MDX pages | Low |
| contentlayer (deprecated) | — | Avoid |
We will use velite for its type safety and build-time validation.
Step 1: Install Dependencies
pnpm add velite
Step 2: Configure Velite
// velite.config.ts
import { defineConfig, defineCollection, s } from "velite";
const posts = defineCollection({
name: "Post",
pattern: "blog/**/*.md",
schema: s.object({
title: s.string().max(120),
slug: s.slug("blog"),
date: s.isodate(),
excerpt: s.string().max(300),
content: s.markdown(),
tags: s.array(s.string()).optional(),
draft: s.boolean().default(false),
}),
});
export default defineConfig({
root: "content",
output: {
data: ".velite",
assets: "public/static",
base: "/static/",
name: "[name]-[hash:6].[ext]",
clean: true,
},
collections: { posts },
});
Step 3: Integrate with Next.js
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config) => {
config.plugins.push(new VeliteWebpackPlugin());
return config;
},
};
class VeliteWebpackPlugin {
static started = false;
apply(compiler: unknown) {
// @ts-expect-error - Webpack compiler type
compiler.hooks.beforeCompile.tapPromise("VeliteWebpackPlugin", async () => {
if (VeliteWebpackPlugin.started) return;
VeliteWebpackPlugin.started = true;
const { build } = await import("velite");
await build({ watch: process.env.NODE_ENV === "development", clean: false });
});
}
}
export default nextConfig;
Step 4: Create Content
<!-- content/blog/my-first-post.md -->
---
title: "My First Blog Post"
slug: "my-first-post"
date: "2026-01-15"
excerpt: "This is my first blog post. Welcome to the blog."
tags: ["introduction", "blog"]
draft: false
---
# My First Blog Post
Welcome to the blog. This is a **Markdown** file that gets converted to HTML at build time.
## Code Example
\`\`\`typescript
function greet(name: string) {
return \`Hello, \${name}!\`;
}
\`\`\`
## Lists
- First item
- Second item
- Third item
Step 5: Blog Index Page
// app/blog/page.tsx
import { posts } from "#site/content";
import Link from "next/link";
export default function BlogPage() {
const publishedPosts = posts
.filter((post) => !post.draft)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return (
<main className="mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">Blog</h1>
<div className="mt-8 space-y-8">
{publishedPosts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group">
<h2 className="text-2xl font-semibold group-hover:text-blue-600">
{post.title}
</h2>
<time className="text-sm text-gray-500">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<p className="mt-2 text-gray-600 dark:text-gray-400">
{post.excerpt}
</p>
</Link>
</article>
))}
</div>
</main>
);
}
Step 6: Individual Post Page
// app/blog/[slug]/page.tsx
import { posts } from "#site/content";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = posts.find((p) => p.slug === slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = posts.find((p) => p.slug === slug);
if (!post || post.draft) {
notFound();
}
return (
<main className="mx-auto max-w-3xl px-4 py-16">
<article>
<h1 className="text-4xl font-bold">{post.title}</h1>
<time className="mt-2 block text-sm text-gray-500">
{new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<div
className="prose mt-8 dark:prose-invert"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</main>
);
}
Step 7: Add Syntax Highlighting
Install a syntax highlighter for code blocks:
pnpm add rehype-pretty-code shiki
Update your velite config to use the rehype plugin for code block highlighting.
Step 8: RSS Feed
// app/feed.xml/route.ts
import { posts } from "#site/content";
export async function GET() {
const baseUrl = "https://yourdomain.com";
const publishedPosts = posts
.filter((post) => !post.draft)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Your Blog</title>
<link>${baseUrl}/blog</link>
<description>Latest posts from our blog</description>
<atom:link href="${baseUrl}/feed.xml" rel="self" type="application/rss+xml"/>
${publishedPosts.map((post) => `
<item>
<title>${escapeXml(post.title)}</title>
<link>${baseUrl}/blog/${post.slug}</link>
<description>${escapeXml(post.excerpt)}</description>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<guid>${baseUrl}/blog/${post.slug}</guid>
</item>`).join("")}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/xml",
},
});
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Step 9: Typography Styling
Install Tailwind Typography for beautiful prose:
pnpm add @tailwindcss/typography
The prose class handles all the styling for rendered Markdown content — headings, paragraphs, lists, code blocks, and more.
Project Structure
content/
blog/
my-first-post.md
another-post.md
app/
blog/
page.tsx # Blog index
[slug]/
page.tsx # Individual post
feed.xml/
route.ts # RSS feed
Need a Blog for Your Business?
We build fast, SEO-optimized blogs for businesses using Next.js. Contact us to discuss your content strategy.