Back to Blog

How to Build a Markdown Blog with Next.js 15: The Complete Guide

Cover Image for How to Build a Markdown Blog with Next.js 15: The Complete Guide
Photo by Andrew Neel on Unsplash
Adeel Imran
Adeel Imran

Most developers over complicate their blog setup. They reach for headless CMSs, complex databases, and elaborate authentication systems only to publish a handful of posts per year.

Stop fighting with your CMS. You're a developer and your blog should live where you code.

Here's the truth: a markdown-based blog in Next.js is simpler, faster, and more developer-friendly than any CMS solution. You write posts in your code editor, version control them with Git, and deploy with zero infrastructure to manage.

This is exactly how this blog is built. Let me show you how to create your own Next.js 15 App Router markdown blog with a complete developer blog setup.

TL;DR

If you're short on time, here's the stack:

  • Next.js 15 App Router for static generation
  • Markdown files in a _posts/ folder for content
  • gray-matter to parse frontmatter metadata
  • remark + remark-gfm to convert markdown to HTML
  • Tailwind CSS for styling (optional but recommended)

No database. No CMS. No API keys. Just files and code.


Why Markdown Over a CMS?

Before we build, let's understand why this approach wins for developer blogs. It's the ultimate Next.js filesystem blog without CMS solution:

Feature Markdown Blog Headless CMS
Ownership You own everything Locked into vendor
Version Control Full Git history Limited or none
Offline Editing Works anywhere Needs internet
Cost Free forever Often paid tiers
Deployment Zero config Webhooks, API keys
Speed Static HTML API round-trips

If you're a developer writing technical content, markdown is your native language. Why add a translation layer?


Project Structure

Here's how we'll organize our blog:

app/
├── layout.tsx           # Root layout with metadata
├── page.tsx             # Homepage
└── blog/
    ├── page.tsx         # Blog index (list all posts)
    └── [slug]/
        └── page.tsx     # Individual blog post
_posts/
├── 2025-01-15-my-first-post.md
├── 2025-02-20-another-post.md
└── ...
lib/
├── api.ts               # Functions to read posts
└── markdownToHtml.ts    # Markdown processor
interfaces/
└── post.ts              # TypeScript types

The _posts/ folder holds your content. The lib/ folder handles the logic. Clean separation.


Step 1: Set Up Your Next.js Project

Start with a fresh Next.js 15 installation. This Next.js 15 tutorial assumes you're starting from scratch:

npx create-next-app@latest my-blog --typescript --tailwind --app
cd my-blog

Install the markdown processing dependencies:

npm install gray-matter remark remark-gfm remark-rehype rehype-slug rehype-highlight rehype-stringify highlight.js

What each package does:

  • gray-matter: Parses YAML frontmatter from markdown files
  • remark: Core markdown processor
  • remark-gfm: Adds GitHub-flavored markdown (tables, strikethrough, task lists)
  • remark-rehype: Converts markdown AST to HTML AST
  • rehype-slug: Adds IDs to headings (for anchor links)
  • rehype-highlight: Adds syntax highlighting classes to code blocks
  • rehype-stringify: Converts HTML AST to HTML string
  • highlight.js: Provides the CSS themes for syntax highlighting

Step 2: Define Your Post Type

Create interfaces/post.ts:

export interface Author {
  name: string;
  picture: string;
}

export interface Post {
  slug: string;
  title: string;
  date: string;
  excerpt: string;
  coverImage: string;
  coverImageCredit?: {
    photographerName: string;
    photographerUrl: string;
    sourceUrl: string;
  };
  author: Author;
  ogImage: {
    url: string;
  };
  content: string;
}

This type ensures consistency across your entire blog. Every post will have the same shape.


Step 3: Create the Blog API

This is where the magic will happen for your blog, the core part. We need a way to read our file system and convert those files into structured data our app can use.

Create lib/api.ts:

import { Post } from "@/interfaces/post";
import fs from "fs";
import matter from "gray-matter";
import { join } from "path";

const postsDirectory = join(process.cwd(), "_posts");

export function getPostSlugs() {
  return fs.readdirSync(postsDirectory);
}

export function getPostBySlug(slug: string) {
  const realSlug = slug.replace(/\.md$/, "");
  const fullPath = join(postsDirectory, `${realSlug}.md`);
  const fileContents = fs.readFileSync(fullPath, "utf8");
  const { data, content } = matter(fileContents);

  return { ...data, slug: realSlug, content } as Post;
}

export function getAllPosts(): Post[] {
  const slugs = getPostSlugs();
  const posts = slugs
    .map((slug) => getPostBySlug(slug))
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
  return posts;
}

export function getRandomPosts(count: number): Post[] {
  const allPosts = getAllPosts();
  const shuffled = allPosts.sort(() => 0.5 - Math.random());
  return shuffled.slice(0, count);
}

How it works:

  1. getPostSlugs() reads all filenames from _posts/
  2. getPostBySlug() reads a specific file, parses its frontmatter with gray-matter, and returns a typed Post object
  3. getAllPosts() gets all posts and sorts them by date (newest first)
  4. getRandomPosts() returns a random selection of posts (great for "More Stories" section)

This runs at build time in Next.js, so there's zero runtime cost.


Step 4: Build the Markdown Processor

Think of this as an assembly line for your text. We take raw markdown and pass it through a series of transformers to produce clean, highlighted HTML.

Create lib/markdownToHtml.ts:

import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeSlug from "rehype-slug";
import rehypeHighlight from "rehype-highlight";
import rehypeStringify from "rehype-stringify";

export default async function markdownToHtml(markdown: string) {
  const result = await remark()
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeSlug)
    .use(rehypeHighlight)
    .use(rehypeStringify)
    .process(markdown);
  return result.toString();
}

The remarkGfm plugin gives you:

  • Tables with | column | headers |
  • Strikethrough with ~~deleted text~~
  • Task lists with - [ ] unchecked and - [x] checked
  • Autolinks for URLs

Step 5: Create Your First Post

Create _posts/2025-12-10-hello-world.md:

---
title: "Hello World: My First Blog Post"
excerpt: "Welcome to my new developer blog built with Next.js and markdown."
coverImage: "https://images.unsplash.com/photo-1461749280684-dccba630e2f6"
coverImageCredit:
  photographerName: "Andrew Neel"
  photographerUrl: "https://unsplash.com/@andrewtneel"
  sourceUrl: "https://unsplash.com/photos/macbook-pro-white-ceramic-mugand-black-smartphone-on-table-cckf4TsHAuw"
date: "2025-12-10T09:00:00.000Z"
author:
  name: "Your Name"
  picture: "/profile/avatar.jpeg"
ogImage:
  url: "https://images.unsplash.com/photo-1461749280684-dccba630e2f6"
---

This is the content of my first post.

## A Subheading

You can use all standard markdown features:

- Bullet lists
- **Bold text**
- `inline code`

And code blocks with syntax highlighting:

```javascript
const greeting = "Hello, World!";
console.log(greeting);
```

Tables work too:

| Feature | Supported |
| ------- | --------- |
| Tables  | ✅        |
| Images  | ✅        |
| Code    | ✅        |

Frontmatter fields explained:

  • title: The post headline (used in <title> and <h1>)
  • excerpt: Short description for SEO and previews
  • coverImage: Hero image URL (Unsplash works great)
  • date: ISO 8601 timestamp for sorting and display
  • author: Name and avatar for the byline
  • ogImage: Image for social media previews

Step 6: Build the Blog Index Page

Create app/blog/page.tsx:

import { getAllPosts } from "@/lib/api";
import Link from "next/link";
import Image from "next/image";

export const metadata = {
  title: "Blog",
  description:
    "Read my latest thoughts on web development, React, and TypeScript.",
};

export default function BlogIndex() {
  const posts = getAllPosts();

  return (
    <main className="max-w-4xl mx-auto px-4 py-16">
      <h1 className="text-4xl font-bold mb-12">Blog</h1>

      <div className="grid gap-8">
        {posts.map((post) => (
          <article key={post.slug} className="group">
            <Link href={`/blog/${post.slug}`}>
              <div className="relative aspect-video mb-4 overflow-hidden rounded-lg">
                <Image
                  src={post.coverImage}
                  alt={post.title}
                  fill
                  className="object-cover transition-transform group-hover:scale-105"
                />
              </div>
              <h2 className="text-2xl font-semibold mb-2 group-hover:text-blue-600">
                {post.title}
              </h2>
              <p className="text-gray-600 mb-2">{post.excerpt}</p>
              <time className="text-sm text-gray-500">
                {new Date(post.date).toLocaleDateString("en-US", {
                  year: "numeric",
                  month: "long",
                  day: "numeric",
                })}
              </time>
            </Link>
          </article>
        ))}
      </div>
    </main>
  );
}

Step 7: Build the Individual Post Page

Create app/blog/[slug]/page.tsx:

import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllPosts, getPostBySlug } from "@/lib/api";
import markdownToHtml from "@/lib/markdownToHtml";
import Image from "next/image";
import styles from "./post-body.module.css"; // We'll create this next

type Params = {
  params: Promise<{ slug: string }>;
};

// Generate static pages for all posts at build time
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

// Generate SEO metadata dynamically
export async function generateMetadata(props: Params): Promise<Metadata> {
  const params = await props.params;
  const post = getPostBySlug(params.slug);

  if (!post) return {};

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.date,
      images: [{ url: post.ogImage.url }],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.ogImage.url],
    },
  };
}

export default async function Post(props: Params) {
  const params = await props.params;
  const post = getPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  const content = await markdownToHtml(post.content);

  return (
    <article className="max-w-3xl mx-auto px-4 py-16">
      <header className="mb-12">
        <h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
        <div className="flex items-center gap-4 text-gray-600 mb-8">
          <Image
            src={post.author.picture}
            alt={post.author.name}
            width={48}
            height={48}
            className="rounded-full"
          />
          <div>
            <p className="font-medium">{post.author.name}</p>
            <time>
              {new Date(post.date).toLocaleDateString("en-US", {
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
            </time>
          </div>
        </div>
        <div className="relative aspect-video rounded-lg overflow-hidden">
          <Image
            src={post.coverImage}
            alt={post.title}
            fill
            priority
            className="object-cover"
          />
          {post.coverImageCredit && (
            <div className="absolute bottom-0 right-0 bg-black/50 text-white text-xs px-2 py-1">
              Photo by{" "}
              <a
                href={post.coverImageCredit.photographerUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="underline"
              >
                {post.coverImageCredit.photographerName}
              </a>
            </div>
          )}
        </div>
      </header>

      <div
        className={styles.markdown}
        dangerouslySetInnerHTML={{ __html: content }}
      />
    </article>
  );
}

Key patterns here:

  • generateStaticParams() pre-renders all blog posts at build time
  • generateMetadata() creates unique SEO metadata for each post
  • The priority prop on the cover image optimizes LCP (Largest Contentful Paint)
  • We use a CSS module styles.markdown to style the content safely

Step 8: Add Styling and Syntax Highlighting

Raw HTML is functional but ugly. Instead of fighting with global styles or heavy plugins, we'll use a CSS module for styling. This gives us full control over typography without side effects.

This is how you create a developer blog with Next.js and Tailwind CSS that looks professional:

  1. Create app/blog/[slug]/post-body.module.css:
.markdown {
  @apply text-lg leading-relaxed;
}

.markdown p,
.markdown ul,
.markdown ol,
.markdown blockquote {
  @apply my-6;
}

.markdown h2 {
  @apply text-3xl mt-12 mb-4 leading-snug font-bold;
}

.markdown h3 {
  @apply text-2xl mt-8 mb-4 leading-snug font-bold;
}

.markdown a {
  @apply text-blue-600 hover:underline;
}

.markdown ul {
  @apply list-disc list-outside pl-6;
}

.markdown ol {
  @apply list-decimal list-outside pl-6;
}

.markdown blockquote {
  @apply border-l-4 border-gray-200 pl-4 italic text-gray-600;
}

/* Code blocks */
.markdown pre {
  @apply rounded-lg my-6 overflow-x-auto;
}

.markdown code {
  @apply font-mono text-sm;
}
  1. Add Syntax Highlighting Theme:

Since we used rehype-highlight, we just need to import a theme. Open app/layout.tsx and add:

import "highlight.js/styles/monokai-sublime.css";

Now your code blocks will automatically have beautiful syntax highlighting!

Note: We installed highlight.js earlier specifically to access these theme files. You can swap monokai-sublime for any other theme in the highlight.js/styles folder (you can find this in your node_modules folder).


What About SEO?

A blog is only useful if people can find it. Once your blog is set up, you'll want to implement Next.js blog SEO best practices:

  • Dynamic sitemaps
  • Structured data (JSON-LD)
  • OpenGraph tags for social sharing
  • Proper metadata on every page

I've written a comprehensive guide covering all of this: Complete Next.js SEO Guide: From Zero to Hero. It includes production-ready code for sitemaps, robots.txt, structured data, and more.


Deployment

Your markdown blog is static, which means free hosting:

npm i -g vercel
vercel

That's it. Vercel auto-detects Next.js and deploys with optimal settings.

Other Platforms

  • Netlify: Similar one-click deployment
  • Cloudflare Pages: Great for edge performance
  • GitHub Pages: Free with next export

Common Pitfalls to Avoid

1. Forgetting the Date Format

Dates must be ISO 8601 format: "2025-12-10T09:00:00.000Z"

Not: "December 10, 2025" or "2025-12-10"

2. Missing Alt Text on Images

Always provide meaningful alt text. It's required for accessibility and helps with image SEO.

3. Huge Image Files

Unsplash URLs support query params. Use ?q=80&w=1200 to optimize:

https://images.unsplash.com/photo-xxx?q=80&w=1200&auto=format

The Workflow

Once set up, here's your daily workflow:

  1. Create a new .md file in _posts/ with the naming convention YYYY-MM-DD-slug.md
  2. Add frontmatter with title, excerpt, date, and images
  3. Write your content in markdown
  4. Commit and push to your repository
  5. Auto-deploy via Vercel (or your platform)

No CMS login. No browser-based editor. Just your favorite code editor and Git.


Conclusion

You've just built something that is 100% yours. Congratulations give yourself a pat on the back.

A Next.js markdown blog isn't just simpler. It's better for developers. You get:

  • Full version control over your content
  • Zero vendor lock-in (it's just files)
  • Blazing fast performance (static HTML)
  • Free hosting anywhere
  • Native developer experience (write in VS Code, not a web UI)

The entire implementation fits in about 100 lines of code. The rest is just your content.

Start with the basics here, then extend as needed. Add comments with Giscus. Add analytics with Plausible. Add a newsletter with Buttondown. The foundation is solid. You can build whatever you want on top.


Further Reading

Next Steps:

Official Documentation: