How to Build a High-Performance Markdown Blog with Next.js

In the modern web landscape, performance and SEO are the pillars of a successful blog. While there are many CMS options available, building a Markdown-based blog with Next.js offers a developer-centric workflow, near-instant load times, and total control over your data.
In this guide, we will build a production-ready blog using the Next.js App Router, TypeScript, and Tailwind CSS.
Why Next.js for Markdown Blogs?
Next.js is uniquely suited for content-heavy sites due to its Static Site Generation (SSG) capabilities. By pre-rendering Markdown files into HTML at build time, you get:
Blazing Speed: Minimal JavaScript and no database queries at runtime.
Superior SEO: Search engines receive fully rendered HTML content.
Security: Since there is no database or server-side runtime for the content, the attack surface is virtually zero.
1. Project Setup
First, initialize a new Next.js project. We will use Tailwind CSS for styling and the Typography plugin to handle Markdown formatting.
npx create-next-app@latest my-markdown-blog --typescript --tailwind --eslint
# Follow prompts: App Router (Yes), src/ directory (Yes), Import Alias (Yes)
cd my-markdown-blog
# Install dependencies for parsing Markdown
npm install gray-matter marked
npm install -D @tailwindcss/typography
Configure Tailwind Typography
Open tailwind.config.ts and add the plugin. This allows us to use the prose class to instantly style raw HTML rendered from Markdown.
TypeScript
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};
export default config;
2. Folder Structure
A clean architecture is essential for scalability. We will store our Markdown files in a root-level folder called content/posts.
my-markdown-blog/
├── content/
│ └── posts/
│ ├── hello-world.md
│ └── nextjs-guide.md
├── src/
│ ├── app/
│ │ ├── blog/
│ │ │ └── [slug]/
│ │ │ └── page.tsx
│ ├── lib/
│ │ └── markdown.ts
└── tailwind.config.ts
3. Data Fetching Utility
We need a robust way to read the local filesystem. We'll use the fs module to grab files and gray-matter to extract the metadata (frontmatter).
TypeScript
// src/lib/markdown.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const postsDirectory = path.join(process.cwd(), 'content/posts');
export interface PostMetadata {
title: string;
date: string;
excerpt: string;
slug: string;
}
export function getPostBySlug(slug: string) {
const fullPath = path.join(postsDirectory, `${slug}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
metadata: data as PostMetadata,
content,
};
}
export function getAllPosts(): PostMetadata[] {
const fileNames = fs.readdirSync(postsDirectory);
return fileNames.map((fileName) => {
const slug = fileName.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data } = matter(fileContents);
return {
...(data as PostMetadata),
slug,
};
}).sort((a, b) => (a.date < b.date ? 1 : -1));
}
4. Dynamic Routing & Static Params
To ensure our blog is fast, we use generateStaticParams. This tells Next.js to find all blog slugs at build time and generate static HTML for each.
TypeScript
// src/app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from '@/lib/markdown';
import { marked } from 'marked';
interface Props {
params: { slug: string };
}
// 1. Generate static paths for all posts
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// 2. Render the page
export default function PostPage({ params }: Props) {
const { metadata, content } = getPostBySlug(params.slug);
const htmlContent = marked.parse(content);
return (
<article className="max-w-3xl mx-auto py-12 px-4">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-2">{metadata.title}</h1>
<time className="text-gray-500">{metadata.date}</time>
</header>
{/* The 'prose' class from Tailwind Typography does the heavy lifting */}
<section
className="prose prose-lg dark:prose-invert"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</article>
);
}
5. SEO & Metadata
Next.js makes dynamic SEO simple. By exporting a generateMetadata function, we can populate tags for every individual post.
TypeScript
// Add this to src/app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props) {
const { metadata } = getPostBySlug(params.slug);
return {
title: `${metadata.title} | My Dev Blog`,
description: metadata.excerpt,
openGraph: {
title: metadata.title,
description: metadata.excerpt,
type: 'article',
publishedTime: metadata.date,
},
};
}
Bonus: Adding Syntax Highlighting
If you are building a technical blog, code blocks need to look good. You can enhance the marked parser or use a dedicated library. To keep it simple and high-performance, install highlight.js:
$ npm install highlight.js
Then, in your component, import a theme (e.g., Github Dark) and ensure the code blocks are processed. For a truly "Next-level" experience, consider migrating to next-mdx-remote with rehype-highlight which handles this automatically during the build phase.
Summary
You now have a static-site blog that:
- Reads local Markdown files.
- Pre-renders them into high-performance HTML.
- Automatically styles them with Tailwind Typography.
- Provides dynamic SEO metadata for every post.