Skip to main content
Back to Blog
Tutorials
3 min read
November 28, 2024

How to Build a Blog with Next.js and Markdown (MDX)

Create a blog with Next.js App Router using Markdown or MDX files. Covers content processing, syntax highlighting, SEO, and RSS feeds.

Ryel Banfield

Founder & Lead Developer

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

ApproachBest ForComplexity
veliteType-safe content, validationLow
next-mdx-remoteDynamic MDX from any sourceMedium
@next/mdxSimple MDX pagesLow
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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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.

blogNext.jsMDXMarkdowntutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles