DevStacked
Jul 01, 202616 min read

Complete MDX Blog Setup with next-mdx-remote and Next.js 16 (2026 Beginner Guide)

If you've ever tried writing blog posts in plain Markdown inside a Next.js app, you've probably hit a wall pretty fast. Plain Markdown can't render React components, can't handle custom styled code blocks, and definitely can't drop in an interactive widget in the middle of a paragraph.

That's exactly the gap MDX fills — and next-mdx-remote is the easiest way to load MDX content that lives outside your app folder (like a content/blogs directory) and render it as real React.

In this guide, you'll build a complete, production-ready blog setup using next-mdx-remote in Next.js 16 — the same setup powering this very blog. By the end, you'll have:

  • MDX files stored in a content/blogs folder
  • A reusable getAllPosts() / getPostBySlug() data layer
  • Custom-styled MDX components (headings, code blocks, links, tables)
  • Syntax highlighting with sugar-high
  • remark-gfm for GitHub-flavored Markdown (tables, strikethrough, task lists)
  • Dynamic SEO metadata and JSON-LD structured data per post

Why MDXRemote Instead of @next/mdx?

Next.js offers two different ways to work with MDX, and beginners often mix them up.

@next/mdx treats .mdx files as actual pages/routes. You'd create a file like app/blog/my-post/page.mdx and Next.js compiles it into a route at build time. This works, but it means every blog post is a route file living inside app/ — awkward if you want a content/ folder, dynamic [slug] routes, or to pull posts from a CMS/database later.

next-mdx-remote lets you store MDX content anywhere — a content/blogs folder, a database, a CMS — and render it through a single dynamic route like app/blog/[slug]/page.tsx. You read the raw MDX string yourself (usually with the filesystem), then hand it to the <MDXRemote /> component to compile and render.

💡 Tip: If you want a handful of static pages (like a docs page), @next/mdx is simpler. If you're building a blog with many posts and dynamic routing, next-mdx-remote is the better fit — it's what we'll use here.


What We're Building

content/
  blogs/
    hello-world.mdx
    another-post.mdx

lib/
  posts.ts         reads MDX files, parses frontmatter, sorts posts

app/
  blog/
    [slug]/
      page.tsx               dynamic route rendering MDXRemote
      opengraph-image.tsx     auto-generated OG image per post

mdx-components.tsx   custom styled components used inside MDX

Prerequisites: Next.js 16 (App Router), TypeScript in strict mode, and Tailwind CSS for styling. If your project doesn't already have these, scaffold one with npx create-next-app@latest.


Step 1: Install Dependencies

Run the following command to install everything this setup needs:

npm install next-mdx-remote gray-matter remark-gfm reading-time sugar-high

Here's what each package actually does:

PackagePurpose
next-mdx-remoteCompiles and renders MDX strings as React on the server
gray-matterParses frontmatter (the --- block at the top of your .mdx files)
remark-gfmAdds GitHub-flavored Markdown — tables, strikethrough, task lists
reading-timeCalculates estimated reading time from post content
sugar-highLightweight syntax highlighter for code blocks (no client JS needed)

⚠️ Common Mistake: Don't confuse next-mdx-remote with next-mdx-remote/rsc. The base package is for the Pages Router / client components. For the App Router with Server Components (which is what we want in Next.js 16), you import from next-mdx-remote/rsc instead. We'll use that import throughout this guide.


Step 2: Configure next.config.mjs (Optional)

// next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
    pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
    transpilePackages: ['next-mdx-remote'],
}

export default nextConfig

What's happening here:

  • pageExtensions tells Next.js that .md and .mdx files can also be treated as pages, alongside the usual .ts/.tsx.
  • transpilePackages: ['next-mdx-remote'] makes sure Next.js properly transpiles the package rather than trying to treat it as pre-built.

Step 3: Create the Content Folder

This is where your actual blog posts will live as plain .mdx files.

content/blogs/hello-world.mdx
---
title: "Hello World: My First MDX Post"
description: "A short example post to test out the MDXRemote setup in Next.js 16."
publishedAt: "2026-07-01"
tags: ["MDX", "Next.js"]
featured: false
---

## Hello World

This is my **first** MDX post rendered with `next-mdx-remote`.

- It supports Markdown
- It supports *React components*
- It even supports tables (thanks to remark-gfm)

| Feature            | Supported |
| ------------------ | --------- |
| Tables             |        |
| Code blocks        |        |
| Custom components  |        |

const greeting: string = "Hello from MDX!";
console.log(greeting);

Understanding the frontmatter block:

The section between the --- lines at the top is called frontmatter. It's plain YAML metadata that describes the post — title, description, publish date, tags — without being part of the rendered content itself. gray-matter (installed earlier) is the library that separates this metadata from the actual MDX body.

⚠️ Common Mistake: Keep your frontmatter field names consistent across every post (title, description, publishedAt, tags, featured). If one file has date and another has publishedAt, your sorting and metadata logic will silently break for that post.


Step 4: Build the Data Layer (lib/posts.ts)

This file is the heart of the whole setup — it reads every .mdx file from disk, parses the frontmatter, and returns clean, typed post objects your pages can use.

// lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";

const POSTS_PATH = path.join(process.cwd(), "content/blogs");

export type Post = {
  slug: string;
  title: string;
  description: string;
  publishedAt: string;
  updatedAt?: string;
  tags: string[];
  coverImage?: string;
  readingTime: string;
  content: string;
};

export function getAllPosts(): Post[] {
  const files = fs.readdirSync(POSTS_PATH);

  const posts = files.map((file) => {
    const filePath = path.join(POSTS_PATH, file);
    const fileContent = fs.readFileSync(filePath, "utf-8");

    const { data, content } = matter(fileContent);

    const slug = file.replace(/\.mdx$/, "");

    return {
      slug,
      title: data.title,
      description: data.description,
      publishedAt: data.publishedAt,
      tags: data.tags || [],
      coverImage: data.coverImage || "",
      readingTime: readingTime(content).text,
      content,
    };
  });

  return posts.sort(
    (a, b) =>
      new Date(b.publishedAt).getTime() -
      new Date(a.publishedAt).getTime()
  );
}

export function getPostBySlug(slug: string) {
  return getAllPosts().find((post) => post.slug === slug);
}

export function extractFirstImage(content: string) {
  const regex = /!\[.*?\]\((.*?)\)/;
  const match = content.match(regex);
  return match?.[1] || null;
}

Breaking this down line by line:

  • fs.readdirSync(POSTS_PATH) reads the filenames of every .mdx file inside content/blogs.
  • matter(fileContent) splits each file into data (the frontmatter object) and content (the raw MDX body below the frontmatter).
  • slug is derived directly from the filename — hello-world.mdx becomes the slug hello-world, which later becomes the URL /blog/hello-world.
  • readingTime(content).text automatically calculates something like "3 min read" based on word count — no manual math needed.
  • Posts are sorted newest-first by comparing publishedAt dates.
  • extractFirstImage() is a small helper that scans the Markdown content for the first ![alt](url) image — handy for auto-generating Open Graph preview images later.

💡 Tip: Because this uses fs (Node's filesystem module), it can only run on the server — inside Server Components, generateStaticParams, or generateMetadata. Never import lib/posts.ts into a "use client" component.


Step 5: Create Custom MDX Components

By default, MDX renders plain, unstyled HTML tags (<h1>, <p>, <code>, etc). To make your blog actually look good, you override these tags with your own styled versions.

// mdx-components.tsx
import React, { ComponentPropsWithoutRef } from 'react';
import Link from 'next/link';
import { highlight } from 'sugar-high';

type HeadingProps = ComponentPropsWithoutRef<'h1'>;
type ParagraphProps = ComponentPropsWithoutRef<'p'>;
type ListProps = ComponentPropsWithoutRef<'ul'>;
type ListItemProps = ComponentPropsWithoutRef<'li'>;
type AnchorProps = ComponentPropsWithoutRef<'a'>;
type BlockquoteProps = ComponentPropsWithoutRef<'blockquote'>;

const components = {
    h1: (props: HeadingProps) => (
        <h1 className="scroll-m-20 text-4xl font-bold tracking-tight lg:text-5xl" {...props} />
    ),
    h2: (props: HeadingProps) => (
        <h2 className="scroll-m-20 mt-16 pb-2 text-3xl font-semibold tracking-tight" {...props} />
    ),
    h3: (props: HeadingProps) => (
        <h3 className="scroll-m-20 mt-12 text-2xl font-semibold tracking-tight" {...props} />
    ),
    p: (props: ParagraphProps) => (
        <p className="text-gray-800 dark:text-zinc-300" {...props} />
    ),
    ul: (props: ListProps) => (
        <ul className="text-gray-800 dark:text-zinc-300 list-disc pl-5 space-y-1" {...props} />
    ),
    li: (props: ListItemProps) => <li className="pl-1" {...props} />,
    a: ({ href, children, ...props }: AnchorProps) => {
        const className = 'text-blue-500 hover:text-blue-700';

        // Internal links use Next.js's Link for client-side navigation
        if (href?.startsWith('/')) {
            return <Link href={href} className={className} {...props}>{children}</Link>;
        }

        // External links open in a new tab
        return (
            <a href={href} target="_blank" rel="noopener noreferrer" className={className} {...props}>
                {children}
            </a>
        );
    },
    code: ({ children, ...props }: ComponentPropsWithoutRef<'code'>) => {
        const codeHTML = highlight(String(children));
        return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />;
    },
    blockquote: (props: BlockquoteProps) => (
        <blockquote className="ml-[0.075em] border-l-3 border-green-400 pl-4 text-gray-700" {...props} />
    ),
    em: (props: ComponentPropsWithoutRef<'em'>) => (
        <em className="font-medium" {...props} />
    ),
};

declare global {
    type MDXProvidedComponents = typeof components;
}

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

What's happening here:

  • Every key in the components object (h1, p, code, etc.) maps directly to that HTML tag name inside your MDX files. Write # My Heading in MDX, and it renders through your custom h1 component automatically.
  • The code component is the interesting one — instead of plain unstyled text, it runs the code through sugar-high's highlight() function, which returns syntax-highlighted HTML. This gives you colored code blocks with zero client-side JavaScript, since sugar-high runs entirely at render time on the server.
  • The a component checks whether a link is internal (starts with /) or external. Internal links use Next.js's <Link> for fast client-side navigation; external links get target="_blank" so readers don't lose your site.

⚠️ Common Mistake: Forgetting to style the code component is the #1 reason MDX blogs look broken — code blocks renders with no formatting. Always wire up a syntax highlighter here.


Step 6: Build the Dynamic Blog Route

This is where everything comes together — reading the post, compiling the MDX, and rendering it.

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
import { components } from "@/mdx-components";

interface BlogPostPageProps {
    params: Promise<{ slug: string }>;
}

// Pre-renders every post at build time (Static Site Generation)
export async function generateStaticParams() {
    const posts = getAllPosts();
    return posts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({ params }: BlogPostPageProps) {
    const { slug } = await params;
    const post = getPostBySlug(slug);

    if (!post) return {};

    return {
        title: post.title,
        description: post.description,
    };
}

export default async function BlogPostPage({ params }: BlogPostPageProps) {
    const { slug } = await params;
    const post = getPostBySlug(slug);

    if (!post) {
        notFound();
    }

    return (
        <article className="prose dark:prose-invert mx-auto">
            <h1>{post.title}</h1>

            <MDXRemote
                source={post.content}
                components={components}
                options={{
                    mdxOptions: {
                        remarkPlugins: [remarkGfm],
                    },
                }}
            />
        </article>
    );
}

Understanding each piece:

  • generateStaticParams() tells Next.js every possible slug value ahead of time, so it can statically generate every blog post page at build time instead of rendering it on every request. This is what makes MDX blogs built this way extremely fast.
  • generateMetadata() dynamically sets the <title> and meta description per post — critical for SEO, since every post needs its own unique tags rather than a single site-wide default.
  • notFound() triggers Next.js's built-in 404 page if someone visits a slug that doesn't exist in content/blogs.
  • <MDXRemote source={...} /> is doing the real work: it takes the raw MDX string (post.content), compiles it into React on the server, and renders it — swapping in your custom components along the way.
  • remarkPlugins: [remarkGfm] enables GitHub-flavored Markdown syntax inside this specific render call — tables, ~~strikethrough~~, and - [ ] task lists all now work in your MDX files.

💡 Tip: MDXRemote from next-mdx-remote/rsc is an async Server Component. It streams and renders entirely on the server — no MDX compiler code ever ships to the browser, which keeps your client bundle small.


Step 7: Add JSON-LD Structured Data (Optional, but Great for SEO)

To help Google understand each post is a blog article (and potentially show rich snippets), add structured data using Next.js's <Script> component.

// app/blog/[slug]/page.tsx (addition)
import Script from "next/script";

<>
<article>
rest of the code
</article>
<Script
    type="application/ld+json"
    dangerouslySetInnerHTML={{
        __html: JSON.stringify({
            "@context": "https://schema.org",
            "@type": "BlogPosting",
            headline: post.title,
            description: post.description,
            datePublished: post.publishedAt,
            author: { "@type": "Person", name: "Your Name" },
        }),
    }}
/>
</>

This injects a <script type="application/ld+json"> tag containing structured metadata search engines can parse directly — no visible UI change, just better SEO.


Step 8: Update global.css

Blog content renders as plain HTML elements with no default styling, so headings, paragraphs, and lists will look unstyled out of the box. The Tailwind Typography plugin fixes this in one line.

Install it:

npm install @tailwindcss/typography

Add the plugin to your global CSS:

/* app/globals.css */
@import "tailwindcss";

@plugin "@tailwindcss/typography";

:root{
  /* your other styles */

  /* add code blocks styling */
  --sh-class: #7aa2f7;
  --sh-sign: #89ddff;
  --sh-string: #9ece6a;
  --sh-keyword: #bb9af7;
  --sh-comment: #acacac;
  --sh-jsxliterals: #7aa2f7;
  --sh-property: #73daca;
  --sh-entity: #e0af68;
  --sh-identifier: #fff;
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }

  body {
    @apply bg-background text-foreground;
  }

  button:not(:disabled),
  [role="button"]:not(:disabled) {
    cursor: pointer;
  }

  pre::-webkit-scrollbar {
    display: none;
  }

  code:not(pre code) {
    background-color: var(--color-gray-100);
  }

  code:not(pre code) span {
    font-weight: 600;
    color: black !important;
  }

  hr {
    color: var(--color-gray-200);
  }

  /* Remove Safari input shadow on mobile */
  input[type='text'],
  input[type='email'] {
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
  }

  table {
    display: block;
    max-width: fit-content;
    overflow-x: auto;
    white-space: nowrap;
    text-align: left;
  }
}

Testing Your Setup

Start your dev server:

npm run dev

Then visit:

http://localhost:3000/blog/hello-world

You should see your MDX post fully rendered with styled headings, a working table, and a syntax-highlighted code block.

⚠️ Common Mistake: If you get a "module not found: fs" error, you've likely imported lib/posts.ts inside a Client Component ("use client"). The fs module only works on the server — remove the client directive or move the data fetching to a Server Component / route handler.


Bonus: Dynamic Open Graph Images

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge'; // Use Edge Runtime for faster generation
export const alt = 'Blog Post Preview';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function Image({ params }: { params: Promise<{ slug: string; }> }) {
    const title = (await params).slug.replace(/-/g, ' ');
    
    return new ImageResponse(
        (
            <div
                style={{
                    height: "100%",
                    width: "100%",
                    display: "flex",
                    flexDirection: "column",
                    justifyContent: "space-between",
                    backgroundColor: "#fff",
                    padding: "60px",
                }}
            >

                <div
                    style={{
                        fontSize: 44,
                        display: 'flex',
                        alignItems: 'center',
                        fontWeight: 'bold',
                        gap: '15px'
                    }}
                >
                    <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="green" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" /><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" /><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" /></svg>
                    <span>Website Name</span>
                </div>

                <div
                    style={{
                        fontSize: 64,
                        fontWeight: 700,
                        lineHeight: 1.1,
                        maxWidth: 900,
                        textTransform: 'capitalize'
                    }}
                >
                    {title}
                </div>

                <div
                    style={{
                        fontSize: 28,
                        opacity: 0.7,
                    }}
                >
                    example.com
                </div>
            </div>
        ),
        { ...size }
    );
}


Frequently Asked Questions

Can I use next-mdx-remote with the Pages Router?

Yes, but you'd import from next-mdx-remote (not the /rsc subpath) and use getStaticProps/getStaticPaths instead of generateStaticParams. The /rsc version shown in this guide is built specifically for the App Router's Server Components.

Do I need @next/mdx if I'm already using MDXRemote?

Not strictly — MDXRemote compiles MDX at runtime regardless of your next.config.mjs setup. But registering .mdx as a page extension avoids editor/tooling quirks and keeps the door open if you ever want static .mdx route files too.

How do I add images inside my MDX posts?

Just use standard Markdown image syntax: ![alt text](/path/to/image.png). If you want Next.js's optimized <Image> component instead, add an img key to your components object in mdx-components.tsx that renders next/image under the hood.

Why is my code block not syntax highlighted?

Double-check that your code component in mdx-components.tsx is running the content through sugar-high's highlight() function. If you copy-pasted code but forgot this override, code blocks fall back to plain unstyled text.

Can I fetch MDX content from a CMS instead of the filesystem?

Yes — that's actually one of the biggest advantages of next-mdx-remote over @next/mdx. Since MDXRemote just needs a raw MDX string, you can fetch that string from Contentful, Sanity, a database, or anywhere else instead of fs.readFileSync. The rest of the setup stays identical.


Wrapping Up

You now have a complete, production-ready MDX blog setup running on Next.js 16 — MDX files stored cleanly in content/blogs, a typed data layer for reading and sorting posts, custom-styled components, GitHub-flavored Markdown support, and per-post SEO metadata.

From here, natural next steps include adding tag-based filtering, a search feature, or auto-generated Open Graph images per post using next/og.

Helpful Resources

Related Guides

If you'd like to keep building out your Next.js stack, these guides pair well with this one:

Try These Free Developer Tools

While you're here, a couple of free tools that pair naturally with a fresh MDX blog setup like this one:

  • Meta Tag Generator for Next.js & HTML — generate SEO meta tags, Open Graph tags, Twitter Cards, canonical URLs, and JSON-LD structured data instantly. Handy for wiring up per-post metadata like the generateMetadata() function above.
  • Supabase RLS Policy Generator — if your next project pairs this blog setup with a Supabase backend, generate secure, performance-optimized Row Level Security policies in seconds.
  • Zod Schema Generator — generate Zod schemas instantly from JSON or TypeScript. Includes smart inference, live validation, React Hook Form, Next.js API Routes, and Server Actions.

📦 Source Code: View on GitHub

Next.jsMDXMDXRemotenext-mdx-remoteBlogApp RouterTypeScript
Share On