Type something to search...
Building a Markdown-Powered Blog with Next.js and MDX

Building a Markdown-Powered Blog with Next.js and MDX

A blog built from Markdown files in your repository is hard to beat for a developer's site or documentation. Posts live next to your code, they're versioned with Git, you write them in your editor, and every page can be prerendered into static HTML. There's no database to run and no CMS bill. MDX adds the one thing plain Markdown lacks: the ability to drop React components into a post when you need them.

This post builds that blog from scratch with the Next.js 16 App Router. By the end you'll have a content folder of .mdx files with frontmatter, a small typed content layer that reads them, a blog index, statically generated post pages with proper metadata, tag pages, and draft handling. I'll keep custom MDX components to a minimum here; they get their own post.

Two Ways to Use MDX in Next.js

Before writing code, it's worth picking an approach, because Next.js supports two quite different ones.

@next/mdxnext-mdx-remote
How MDX is treatedAs modules: compiled by the bundler, imported like componentsAs data: read as strings and compiled at render time
FrontmatterNot built in (use exports or a remark plugin)Built in (parseFrontmatter)
Imports inside MDXYesNo; components are passed in as a prop
Content locationInside your projectAnywhere: files, a database, a CMS
Listing postsImport every module or parse files separatelyRead files and parse frontmatter
Good fitMDX pages in app/, docs sectionsBlogs and content collections

For a blog, treating posts as data is the more natural fit. You need to list posts, sort them by date, filter by tag, and hide drafts, which all means reading frontmatter without rendering the whole post. So this guide uses next-mdx-remote for rendering and gray-matter for reading frontmatter. I'll show the @next/mdx setup briefly at the end for comparison.

Project Structure

Here's where everything will live:

my-blog/
  app/
    layout.tsx
    blog/
      page.tsx              # list of posts
      [slug]/
        page.tsx            # a single post
    tags/
      [tag]/
        page.tsx            # posts with a tag
  components/
    mdx.tsx                 # renders MDX with shared options
  content/
    posts/
      hello-world.mdx
      second-post.mdx
  lib/
    posts.ts                # content layer

Install the dependencies:

npm install next-mdx-remote gray-matter remark-gfm rehype-slug
  • next-mdx-remote compiles and renders MDX in Server Components.
  • gray-matter parses the YAML frontmatter at the top of each file.
  • remark-gfm adds GitHub Flavored Markdown: tables, task lists, strikethrough, autolinks.
  • rehype-slug adds id attributes to headings so you can link to sections.

Writing a Post

Each post is an .mdx file with YAML frontmatter followed by the content:

---
title: "Hello, World"
description: "Why I started this blog and what I'll write about."
date: "2026-09-01"
tags: ["meta", "nextjs"]
draft: false
---

Welcome to the blog. I'll be writing about **Next.js**, CSS, and the
occasional detour into tooling.

## What to expect

| Topic   | How often |
| ------- | --------- |
| Next.js | Weekly    |
| CSS     | Monthly   |

- [x] Set up the blog
- [ ] Write the second post

The frontmatter is metadata about the post. The body is Markdown, with the option of using JSX where you need it. The file name becomes the URL slug, so hello-world.mdx is served at /blog/hello-world.

The Content Layer

All file reading goes through one module, lib/posts.ts. Pages never touch the filesystem directly. That keeps the rules (what counts as published, how posts are sorted, how slugs are made) in one place.

// lib/posts.ts
import "server-only";
import fs from "node:fs/promises";
import path from "node:path";
import { cache } from "react";
import matter from "gray-matter";

const POSTS_DIR = path.join(process.cwd(), "content", "posts");

export type PostFrontmatter = {
  title: string;
  description: string;
  date: string;
  tags: string[];
  draft?: boolean;
};

export type PostMeta = PostFrontmatter & {
  slug: string;
  readingMinutes: number;
};

export type Post = PostMeta & { content: string };

function isPublished(fm: PostFrontmatter) {
  if (fm.draft && process.env.NODE_ENV === "production") return false;
  return new Date(fm.date).getTime() <= Date.now();
}

function readingMinutes(content: string) {
  const words = content.trim().split(/\s+/).length;
  return Math.max(1, Math.round(words / 230));
}

function parseFrontmatter(data: Record<string, unknown>, file: string) {
  const { title, description, date, tags, draft } = data;
  if (typeof title !== "string" || typeof date !== "string") {
    throw new Error(`Missing title or date in ${file}`);
  }
  return {
    title,
    description: typeof description === "string" ? description : "",
    date,
    tags: Array.isArray(tags) ? tags.map(String) : [],
    draft: draft === true,
  } satisfies PostFrontmatter;
}

async function readPostFile(slug: string): Promise<Post | null> {
  const file = path.join(POSTS_DIR, `${slug}.mdx`);
  let raw: string;
  try {
    raw = await fs.readFile(file, "utf8");
  } catch {
    return null;
  }
  const { data, content } = matter(raw);
  const frontmatter = parseFrontmatter(data, file);
  return {
    ...frontmatter,
    slug,
    content,
    readingMinutes: readingMinutes(content),
  };
}

export const getAllPosts = cache(async (): Promise<PostMeta[]> => {
  const files = await fs.readdir(POSTS_DIR);
  const slugs = files
    .filter((f) => f.endsWith(".mdx") && !f.startsWith("_"))
    .map((f) => f.replace(/\.mdx$/, ""));

  const posts = await Promise.all(slugs.map(readPostFile));

  return posts
    .filter((p): p is Post => p !== null && isPublished(p))
    .map(({ content: _content, ...meta }) => meta)
    .sort((a, b) => (a.date < b.date ? 1 : -1));
});

export const getPost = cache(async (slug: string): Promise<Post | null> => {
  const post = await readPostFile(slug);
  if (!post || !isPublished(post)) return null;
  return post;
});

export const getAllTags = cache(async () => {
  const posts = await getAllPosts();
  const counts = new Map<string, number>();
  for (const post of posts) {
    for (const tag of post.tags) {
      counts.set(tag, (counts.get(tag) ?? 0) + 1);
    }
  }
  return [...counts.entries()]
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count);
});

What each part is doing:

  • import "server-only" makes the build fail if a Client Component ever imports this module. It uses fs, so it must never end up in a browser bundle. Install the package with npm install server-only.
  • process.cwd() resolves paths from the project root. Don't use __dirname; the compiled file won't be where the source file is.
  • parseFrontmatter validates the shape. Frontmatter is untyped YAML, and a missing title should fail loudly at build time rather than render "undefined" in production. If you use Zod elsewhere, a schema works well here too.
  • isPublished hides drafts in production builds (but shows them in next dev so you can preview) and hides posts dated in the future.
  • cache from React deduplicates calls within a single render. The post page calls getPost from both generateMetadata and the page component; with cache, the file is read once.
  • getAllPosts returns metadata only. The index page doesn't need post bodies, so they're dropped to keep the data small.
  • Sorting by string works because dates are ISO formatted (YYYY-MM-DD), which sort correctly as strings.

One thing to understand about future-dated posts: because everything is prerendered at build time, a post scheduled for tomorrow appears only after the next build that runs after that date. Scheduled publishing therefore needs a scheduled rebuild (a daily CI job, for example).

Rendering MDX

Create one component that renders MDX with your plugins and components, so every page uses identical settings:

// components/mdx.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import type { MDXComponents } from "mdx/types";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";

const components: MDXComponents = {
  a: ({ href = "", ...props }) => {
    const externalProps = href.startsWith("http")
      ? { target: "_blank", rel: "noopener noreferrer" }
      : {};
    return <a href={href} {...props} {...externalProps} />;
  },
};

export function Mdx({ source }: { source: string }) {
  return (
    <MDXRemote
      source={source}
      components={components}
      options={{
        mdxOptions: {
          remarkPlugins: [remarkGfm],
          rehypePlugins: [rehypeSlug],
        },
      }}
    />
  );
}

MDXRemote from next-mdx-remote/rsc is an async Server Component. It compiles the MDX string on the server and returns rendered React elements, so no MDX compiler or post content is shipped to the browser as JavaScript. The components map lets you replace HTML elements; here, external links open in a new tab. The mdx/types import comes from the @types/mdx package (npm install -D @types/mdx).

A note on security: since version 6, next-mdx-remote blocks JavaScript expressions like {new Date().getFullYear()} inside MDX by default. Your own content in your own repository is trusted, so if you want expressions, you can pass blockJS: false in options. Never do that for content that comes from users.

The Blog Index

The index lists every published post, newest first:

// app/blog/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";

export const metadata: Metadata = {
  title: "Blog",
  description: "Articles on Next.js, CSS, and web development.",
};

const dateFormat = new Intl.DateTimeFormat("en-US", {
  dateStyle: "long",
  timeZone: "UTC",
});

export default async function BlogPage() {
  const posts = await getAllPosts();

  return (
    <main className="mx-auto max-w-2xl px-4 py-12">
      <h1 className="text-3xl font-bold">Blog</h1>
      <ul className="mt-8 space-y-8">
        {posts.map((post) => (
          <li key={post.slug}>
            <Link href={`/blog/${post.slug}`} className="text-xl font-semibold">
              {post.title}
            </Link>
            <p className="mt-1 text-sm text-gray-500">
              {dateFormat.format(new Date(post.date))} · {post.readingMinutes}{" "}
              min read
            </p>
            <p className="mt-2">{post.description}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

This is a Server Component reading from the content layer. Since nothing in it depends on the request, Next.js prerenders it at build time. The date formatter uses timeZone: "UTC" so a date like 2026-09-01 doesn't display as August 31 for a server in a timezone west of UTC.

The Post Page

The post page does three jobs: tells Next.js which slugs exist, generates metadata, and renders the post.

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Mdx } from "@/components/mdx";
import { getAllPosts, getPost } from "@/lib/posts";

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

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export const dynamicParams = false;

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: "article",
      title: post.title,
      description: post.description,
      publishedTime: post.date,
      tags: post.tags,
    },
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <main className="mx-auto max-w-2xl px-4 py-12">
      <article>
        <header className="mb-8">
          <h1 className="text-4xl font-bold">{post.title}</h1>
          <p className="mt-2 text-sm text-gray-500">
            {post.date} · {post.readingMinutes} min read
          </p>
          <ul className="mt-3 flex gap-2">
            {post.tags.map((tag) => (
              <li key={tag}>
                <Link href={`/tags/${tag}`} className="text-sm underline">
                  #{tag}
                </Link>
              </li>
            ))}
          </ul>
        </header>
        <div className="prose prose-lg dark:prose-invert">
          <Mdx source={post.content} />
        </div>
      </article>
    </main>
  );
}

Going through it:

  • generateStaticParams returns every published slug, so next build prerenders one HTML page per post.
  • dynamicParams = false turns any slug not in that list into a 404, without trying to render it on demand. For a file-based blog, the set of posts is fully known at build time, so this is the right setting.
  • params is a promise in Next.js 16, awaited in both generateMetadata and the page.
  • generateMetadata builds title, description, canonical URL, and Open Graph tags from frontmatter. The Metadata API post goes deeper on these fields.
  • notFound() handles the edge case of a post that exists but isn't published.

Styling Post Content

Markdown renders as plain HTML elements: h2, p, ul, table, pre. Rather than styling each by hand, the Tailwind Typography plugin gives you sensible defaults through the prose class used above. With Tailwind CSS v4, install it and register it in your CSS:

npm install -D @tailwindcss/typography
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

prose styles everything inside it, prose-lg bumps the size, and dark:prose-invert switches to light-on-dark colors in dark mode. You can customize individual elements with modifiers like prose-a:text-blue-600 or prose-headings:font-semibold. If you're not using Tailwind, a stylesheet scoped to a wrapper class does the same job; see how to integrate CSS and Sass in Next.js.

Tag Pages

Tags come from frontmatter, so tag pages are built from the same content layer:

// app/tags/[tag]/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllPosts, getAllTags } from "@/lib/posts";

type Props = { params: Promise<{ tag: string }> };

export async function generateStaticParams() {
  const tags = await getAllTags();
  return tags.map(({ tag }) => ({ tag }));
}

export const dynamicParams = false;

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { tag } = await params;
  return { title: `Posts tagged "${tag}"` };
}

export default async function TagPage({ params }: Props) {
  const { tag } = await params;
  const posts = (await getAllPosts()).filter((p) => p.tags.includes(tag));
  if (posts.length === 0) notFound();

  return (
    <main className="mx-auto max-w-2xl px-4 py-12">
      <h1 className="text-3xl font-bold">#{tag}</h1>
      <ul className="mt-8 space-y-4">
        {posts.map((post) => (
          <li key={post.slug}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Keep tags lowercase and URL-safe in frontmatter (nextjs, not Next.js), or add a slugify step in the content layer and store both the display name and the slug. Mixed-case tags are the most common cause of duplicate tag pages.

Previous and Next Links

Readers who finish a post often want the next one. Since getAllPosts returns a sorted list, adjacent posts are an index lookup:

// lib/posts.ts (add to the file)
export async function getAdjacentPosts(slug: string) {
  const posts = await getAllPosts();
  const index = posts.findIndex((p) => p.slug === slug);
  return {
    newer: index > 0 ? posts[index - 1] : null,
    older: index >= 0 && index < posts.length - 1 ? posts[index + 1] : null,
  };
}

Render them at the bottom of the post page with two Link components. Because the list is cached per render, this adds no extra file reads.

Working With Drafts

With the isPublished rule above, a post with draft: true shows up in next dev but is excluded from production builds, including from generateStaticParams, so its URL returns a 404. A few habits help:

  • Prefix files you're not ready to share even locally with an underscore (_idea.mdx). The content layer skips those entirely.
  • Show a visible "Draft" badge on the post page when post.draft is true, so you never mistake a preview for the live version.
  • Exclude drafts from anything generated from the same data: your sitemap, RSS feed, and search index all should call getAllPosts, never read the folder themselves.

The @next/mdx Alternative

If you'd rather have posts compiled by the bundler, @next/mdx treats MDX files as modules. Setup involves a config wrapper and a required mdx-components.tsx file at the project root:

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
// next.config.mjs
import createMDX from "@next/mdx";

const nextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};

const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-gfm", "remark-frontmatter"],
  },
});

export default withMDX(nextConfig);
// mdx-components.tsx
import type { MDXComponents } from "mdx/types";

const components: MDXComponents = {};

export function useMDXComponents(): MDXComponents {
  return components;
}

Plugins are listed by name as strings, which is what Turbopack (the default bundler in Next.js 16) requires, since JavaScript functions can't be passed to its Rust core. remark-frontmatter strips the YAML block so it isn't rendered as text; you'd still read the frontmatter with gray-matter for listings. Posts are then loaded with a dynamic import, for example await import(`@/content/posts/${slug}.mdx`), and can import components directly at the top of the file.

It's a good option when MDX pages live alongside your routes, like a docs section. For a blog where posts are data to be listed and filtered, the data approach above tends to be simpler.

Performance and Deployment

Because every post, tag, and index page is prerendered, the deployed site serves static HTML. The filesystem is only read during next build, so it doesn't matter whether your host has the content folder available at runtime.

A few things keep builds quick as the blog grows:

  • Read frontmatter for listings without compiling MDX, as getAllPosts does. Compiling every post just to list them is the most common cause of slow builds.
  • Keep cache around content-layer functions so each file is read once per render.
  • Optimize images referenced in posts. Map Markdown images to next/image through the components map, which is covered along with other custom components in creating custom MDX components and shortcodes.

Conclusion

A Markdown-powered blog in Next.js comes down to three pieces: .mdx files with frontmatter in a content folder, a small server-only content layer that reads, validates, filters, and sorts them, and statically generated routes that render posts with next-mdx-remote and generateStaticParams. Everything else (tags, adjacent links, drafts, metadata) builds on that same content layer, which is why keeping all file access in one module matters. From here, the natural next steps are custom MDX components, syntax highlighting for code blocks, and an RSS feed, all of which plug into the structure you've just built.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading