Adding .md URLs for Raw Markdown Content in Next.js
4 months ago
12
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! 🚀
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.
vercel.com/docs/llms.txt is now live 🤖
We also have the full version if you want to read a 400,000 word novel.
This also means you can drop .md on the end of any docs link.
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"---## WelcomeThis is my first blog post! Here's some **bold text** and a code block:```javascriptconsole.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 }));}
This is where Next.js rewrites shine - we can elegantly handle URL rewriting with just a few lines of configuration.
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 }));}
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.