Ben Gubler
Ben Gubler
5 min read

Adding .md URLs for Raw Markdown Content in Next.js

How to add .md URLs to your Next.js blog to serve raw markdown content, inspired by Vercel's docs.

#nextjs

Update: After publishing this post, Guillermo Rauch (CEO of Vercel) suggested using Next.js rewrites instead of middleware for this use case. I've updated the implementation below - it's simpler and more performant! 🚀

TL;DR

Inspired by Vercel's docs, we'll add the ability to append .md to any blog post URL to get the raw markdown content. So /posts/my-post becomes /posts/my-post.md for the raw source. I recently added this feature to my own blog - it's perfect for sharing code examples or letting people see how you wrote something.

Next.js rewrites make this surprisingly easy to implement cleanly.

Setup

pnpx create-next-app@latest raw-markdown-blog
cd raw-markdown-blog
pnpm install @content-collections/core @content-collections/mdx @content-collections/next zod

Choose TypeScript, Tailwind CSS, and App Router.

Add .content-collections to your .gitignore:

.content-collections

Content Collections Setup

Content Collections is an excellent library for managing content in Next.js - it's type-safe, fast, and has great DX.

Create content-collections.ts in your project root (not in src/):

import { defineCollection, defineConfig } from "@content-collections/core";
import { compileMDX } from "@content-collections/mdx";
import { z } from "zod";
 
const posts = defineCollection({
  name: "posts",
  directory: "content",
  include: "**/*.mdx",
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.string().pipe(z.coerce.date()),
  }),
  transform: async (document, context) => {
    const mdx = await compileMDX(context, document);
    const slug = document._meta.path.replace(/\.mdx$/, "");
 
    return {
      ...document,
      mdx,
      slug,
      url: `/posts/${slug}`,
    };
  },
});
 
export default defineConfig({
  collections: [posts],
});

Update next.config.js:

const { withContentCollections } = require("@content-collections/next");
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // your existing config...
};
 
module.exports = withContentCollections(nextConfig);

Update tsconfig.json paths:

{
  "compilerOptions": {
    // ... other options
    "paths": {
      "@/*": ["./src/*"],
      "content-collections": ["./.content-collections/generated"]
    }
  }
}

Sample Content

Create the content/ directory in your project root and add content/hello-world.mdx:

---
title: "Hello World"
description: "My first blog post with raw markdown support."
date: "2024-12-20"
---
 
## Welcome
 
This is my first blog post! Here's some **bold text** and a code block:
 
```javascript
console.log("Hello, world!");
```
 
Pretty cool, right?

Posts Pages

Replace app/page.tsx:

import { allPosts } from "content-collections";
import Link from "next/link";
 
export default function Home() {
  const sortedPosts = allPosts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
  );
 
  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">My Blog</h1>
      <div className="space-y-6">
        {sortedPosts.map((post) => (
          <article key={post.slug} className="border-b pb-4">
            <Link
              href={post.url}
              className="text-xl font-semibold hover:text-blue-600"
            >
              {post.title}
            </Link>
            <p className="text-gray-600 mt-2">{post.description}</p>
            <time className="text-sm text-gray-500">
              {post.date.toLocaleDateString()}
            </time>
            <div className="mt-2 text-sm">
              <Link
                href={`${post.url}.md`}
                className="text-blue-500 hover:underline"
              >
                View raw markdown
              </Link>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

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

import { allPosts } from "content-collections";
import { MDXContent } from "@content-collections/mdx/react";
import { notFound } from "next/navigation";
import Link from "next/link";
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = allPosts.find((p) => p.slug === slug);
 
  if (!post) notFound();
 
  return (
    <div className="max-w-2xl mx-auto p-8">
      <Link
        href="/"
        className="text-blue-600 hover:underline mb-4 inline-block"
      >
        ← Back to posts
      </Link>
 
      <article>
        <header className="mb-8">
          <h1 className="text-3xl font-bold mb-2">{post.title}</h1>
          <p className="text-gray-600 mb-2">{post.description}</p>
          <time className="text-sm text-gray-500">
            {post.date.toLocaleDateString()}
          </time>
          <div className="mt-4">
            <Link
              href={`${post.url}.md`}
              className="text-blue-500 hover:underline text-sm"
            >
              View raw markdown
            </Link>
          </div>
        </header>
 
        <div className="prose prose-lg max-w-none">
          <MDXContent code={post.mdx} />
        </div>
      </article>
    </div>
  );
}
 
export function generateStaticParams() {
  return allPosts.map((post) => ({ slug: post.slug }));
}

The Magic: Rewrites

This is where Next.js rewrites shine - we can elegantly handle URL rewriting with just a few lines of configuration.

Update next.config.js to add the rewrite rule:

const { withContentCollections } = require("@content-collections/next");
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: "/posts/:slug.md",
        destination: "/api/posts/:slug/raw",
      },
    ];
  },
};
 
module.exports = withContentCollections(nextConfig);

The rewrite rule automatically maps any request matching /posts/:slug.md to /api/posts/:slug/raw. The :slug parameter is captured from the source URL and passed to the destination. The user sees /posts/hello-world.md in their browser, but Next.js serves it from /api/posts/hello-world/raw.

API Route for Raw Content

Create app/api/posts/[slug]/raw/route.ts:

import { allPosts } from "content-collections";
import { NextRequest, NextResponse } from "next/server";
 
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const post = allPosts.find((p) => p.slug === slug);
 
  if (!post) {
    return new NextResponse("Post not found", { status: 404 });
  }
 
  return new NextResponse(post.content, {
    headers: {
      "Content-Type": "text/markdown; charset=utf-8",
      "Cache-Control": "public, max-age=3600", // Cache for 1 hour
    },
  });
}
 
export function generateStaticParams() {
  return allPosts.map((post) => ({ slug: post.slug }));
}

Done

Start your dev server and test both URLs:

  • /posts/hello-world - Rendered MDX with styling and components
  • /posts/hello-world.md - Raw markdown source

The cache headers ensure the raw markdown is cached for an hour, reducing server load for popular posts. In production, you might want to add a "View raw" button to your posts (like I did on my own blog) rather than just showing the link in the post listing.

This feature is perfect for sharing examples, debugging content, or letting others study your markdown formatting. And Next.js rewrites make the implementation clean and performant - no complex routing logic needed.

Share this post