Loading
Create a full-featured blog engine with MDX content, dynamic routes, tag filtering, RSS feed generation, and syntax highlighting.
A blog engine is one of the most practical projects you can build as a developer. It forces you to think about content management, routing, SEO, and performance — all skills that transfer directly to production applications.
In this tutorial, you will build a blog engine powered by Next.js and MDX. Your blog will support frontmatter metadata, dynamic routes for each post, tag-based filtering, an RSS feed for subscribers, and syntax highlighting for code blocks. By the end, you will have a deployable blog that rivals platforms like Medium or Hashnode, but one you fully own and control.
The stack is intentionally minimal: Next.js for the framework, next-mdx-remote for rendering MDX, shiki for syntax highlighting, and the built-in fs module for reading content from disk. No database required.
Start by creating a new Next.js project with TypeScript and the App Router.
Create the content directory structure where your MDX posts will live:
Each post will be an .mdx file with frontmatter at the top and markdown content below. This file-based approach means your content lives in version control alongside your code — no CMS dependency, no database, fully portable.
Create a utility module that reads and parses your MDX files. This is the backbone of your blog engine.
The gray-matter library extracts frontmatter from MDX files into structured objects. The reading-time library calculates estimated read time from word count. Both are standard tools in the static blog ecosystem.
Next.js App Router uses folder-based routing. Create a dynamic route that renders individual posts.
The generateStaticParams function tells Next.js which slugs to pre-render at build time. This gives you static HTML for every post — fast load times and zero server cost.
Build the index page that displays all posts with their metadata.
Allow readers to filter posts by tag. Use URL search parameters so filtered views are shareable and bookmarkable.
Use shiki to add beautiful syntax highlighting to code blocks in your MDX content. Create custom MDX components that intercept code blocks.
Then create a custom pre component that uses this highlighter and pass it to MDXRemote via the components prop. Shiki generates HTML with inline styles, so the highlighting works without any client-side JavaScript.
RSS feeds let readers subscribe to your blog using feed readers. Generate the feed as an XML route.
Add a <link> tag in your root layout's <head> so browsers and feed readers can auto-discover your RSS feed: <link rel="alternate" type="application/rss+xml" href="/feed.xml" />.
Your blog engine is complete. Deploy it to Vercel for free hosting with automatic builds on every git push.
Vercel will detect your Next.js project and configure the build automatically. Every time you push a new MDX file to your repository, Vercel rebuilds the site and your new post goes live.
Add a sitemap.xml route using the same pattern as the RSS feed to improve SEO. Generate <meta> tags dynamically in each post page using the generateMetadata export. These two additions take your blog from a side project to a production-ready publishing platform.
You now have a blog engine that is fully static, incredibly fast, and entirely under your control. No vendor lock-in, no monthly fees, no database to maintain. Just MDX files in a git repository and a deploy pipeline that takes care of the rest.
npx create-next-app@latest my-blog --typescript --app --tailwind
cd my-blog
npm install next-mdx-remote shiki gray-matter reading-timemkdir -p src/content/posts// src/lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";
interface PostFrontmatter {
title: string;
description: string;
date: string;
tags: string[];
published: boolean;
}
interface Post {
slug: string;
frontmatter: PostFrontmatter;
content: string;
readingTime: string;
}
const POSTS_DIR = path.join(process.cwd(), "src/content/posts");
export function getAllPosts(): Post[] {
const files = fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith(".mdx"));
return files
.map((filename) => {
const filePath = path.join(POSTS_DIR, filename);
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug: filename.replace(".mdx", ""),
frontmatter: data as PostFrontmatter,
content,
readingTime: readingTime(content).text,
};
})
.filter((post) => post.frontmatter.published)
.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
);
}
export function getPostBySlug(slug: string): Post | undefined {
return getAllPosts().find((post) => post.slug === slug);
}
export function getAllTags(): string[] {
const tags = getAllPosts().flatMap((post) => post.frontmatter.tags);
return [...new Set(tags)].sort();
}// src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) {
notFound();
}
return (
<article className="mx-auto max-w-2xl py-16">
<header className="mb-8">
<h1 className="text-4xl font-bold">{post.frontmatter.title}</h1>
<p className="mt-2 text-gray-500">
{post.frontmatter.date} · {post.readingTime}
</p>
<div className="mt-4 flex gap-2">
{post.frontmatter.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-gray-100 px-3 py-1 text-sm"
>
{tag}
</span>
))}
</div>
</header>
<div className="prose prose-lg">
<MDXRemote source={post.content} />
</div>
</article>
);
}// src/app/blog/page.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
export default function BlogIndex() {
const posts = getAllPosts();
return (
<div className="mx-auto max-w-2xl py-16">
<h1 className="mb-12 text-4xl font-bold">Blog</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group block">
<h2 className="text-2xl font-semibold group-hover:text-blue-600">
{post.frontmatter.title}
</h2>
<p className="mt-1 text-gray-500">
{post.frontmatter.date} · {post.readingTime}
</p>
<p className="mt-2 text-gray-700">
{post.frontmatter.description}
</p>
</Link>
</article>
))}
</div>
</div>
);
}// src/app/blog/tag/[tag]/page.tsx
import Link from "next/link";
import { getAllPosts, getAllTags } from "@/lib/posts";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{ tag: string }>;
}
export async function generateStaticParams() {
return getAllTags().map((tag) => ({ tag }));
}
export default async function TagPage({ params }: PageProps) {
const { tag } = await params;
const posts = getAllPosts().filter((post) =>
post.frontmatter.tags.includes(tag)
);
if (posts.length === 0) {
notFound();
}
return (
<div className="mx-auto max-w-2xl py-16">
<h1 className="mb-4 text-4xl font-bold">Tagged: {tag}</h1>
<p className="mb-12 text-gray-500">{posts.length} posts</p>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2 className="text-2xl font-semibold hover:text-blue-600">
{post.frontmatter.title}
</h2>
<p className="mt-1 text-gray-500">{post.frontmatter.date}</p>
</Link>
</article>
))}
</div>
</div>
);
}// src/lib/highlight.ts
import { createHighlighter } from "shiki";
let highlighter: Awaited<ReturnType<typeof createHighlighter>> | null = null;
export async function highlight(code: string, lang: string): Promise<string> {
if (!highlighter) {
highlighter = await createHighlighter({
themes: ["github-dark"],
langs: ["typescript", "javascript", "bash", "json", "css", "html"],
});
}
return highlighter.codeToHtml(code, { lang, theme: "github-dark" });
}// src/app/feed.xml/route.ts
import { getAllPosts } from "@/lib/posts";
export async function GET(): Promise<Response> {
const posts = getAllPosts();
const siteUrl = "https://yourblog.com";
const xml = `<?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>A developer blog</description>
<atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
${posts
.map(
(post) => `
<item>
<title>${post.frontmatter.title}</title>
<link>${siteUrl}/blog/${post.slug}</link>
<description>${post.frontmatter.description}</description>
<pubDate>${new Date(post.frontmatter.date).toUTCString()}</pubDate>
<guid>${siteUrl}/blog/${post.slug}</guid>
</item>`
)
.join("")}
</channel>
</rss>`;
return new Response(xml, {
headers: { "Content-Type": "application/xml" },
});
}npm run build
npx vercel