# Build a Blog with Next.js and MDX from Scratch

> File-based content, zero database, full control. A complete walkthrough of building a statically-generated blog with Next.js, MDX, and gray-matter.

- Author: Aayush Bharti
- Published: 2025-03-12
- Updated: 2026-02-21
- Reading time: 11 min
- Tags: nextjs, mdx, react, tutorial
- URL: https://aayushbharti.in/blog/build-a-blog-with-nextjs-and-mdx

---
Every blog platform wants to be your landlord. They give you a database you can't export, an editor you can't customize, and a design system that looks identical to the thirty thousand other blogs on the same platform. Then they raise the price.

MDX with Next.js App Router flips that entire dynamic: your content lives as files in git, renders through React components you control, and deploys as static HTML to whatever host you choose. No vendor lock-in, no migration anxiety, no monthly invoice for the privilege of writing markdown. You own the content, the rendering pipeline, and the styling.

This is the setup I use for this blog. I'm going to walk you through building it from scratch — TypeScript, App Router, server components, the works.

## The stack (three packages, that's it)

Before we start scaffolding, here's what we're installing and why each one earns its spot:

- **[next-mdx-remote](https://github.com/hashicorp/next-mdx-remote)** — compiles and renders MDX inside React Server Components without bundling content at build time
- **[gray-matter](https://github.com/jonschlinkert/gray-matter)** — extracts YAML frontmatter from your `.mdx` files into typed JavaScript objects
- **[reading-time](https://github.com/ngryman/reading-time)** — estimates how long a post takes to read, so you can display "5 min read" without guessing

That's the entire content layer. No CMS SDK, no GraphQL client, no ORM. If you're coming from a [terminal-first setup](/blog/terminal-first-dev-setup), this will feel familiar — everything is files and functions.

```bash title="Terminal"
pnpm create next-app@latest mdx-blog --typescript --tailwind --app --src-dir=false
cd mdx-blog
pnpm add next-mdx-remote gray-matter reading-time
```

## Project structure

The App Router convention makes the mapping between URLs and files dead obvious. Content goes in `content/blog/`, utilities go in `lib/`, and the two route files handle listing and rendering.

```
app/
  blog/
    [slug]/
      page.tsx
    page.tsx
  layout.tsx
  page.tsx
content/
  blog/
    my-first-post.mdx
    building-in-public.mdx
lib/
  content.ts
```

Each `.mdx` file in `content/blog/` becomes a blog post. The filename is the slug. No routing config, no database entries, no CMS sync — drop a file in and it exists.

Want to delete a post? Delete the file. Want to rename a URL? Rename the file. The filesystem is the single source of truth, and git is your audit trail.

Notice there's no `components/` or `styles/` folder in this tree — we'll add custom components later. Start lean, add complexity when you have a reason to.

## Frontmatter contract

Before writing any loader code, define what every blog post must include. This is a contract between your content and your rendering layer — if a field is missing, TypeScript catches it at build time instead of your readers catching a broken page in production.

```ts title="lib/content.ts"
interface BlogFrontmatter {
  title: string;
  publishedAt: string;
  description: string;
  image: string;
  author: string;
  tags: string[];
}

interface BlogPost {
  slug: string;
  frontmatter: BlogFrontmatter;
  content: string; // [!code highlight]
  readingTime: string;
}
```

The `content` field holds the raw MDX string — we don't serialize it ahead of time because `next-mdx-remote/rsc` handles compilation inside the server component. That distinction matters: the RSC version compiles on the server during rendering, not during a separate build step.

> **Why type frontmatter?**
>
> I've shipped posts with missing `description` fields before. The page rendered fine — until social previews showed "undefined" as the meta description across every platform. Type-safe frontmatter prevents that entire class of bug.

## Content layer

All the filesystem logic lives in one file. Two functions, no abstractions, no class hierarchy. The file system is the database and `gray-matter` is the query engine.

```ts title="lib/content.ts"
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import readingTime from "reading-time";

const POSTS_DIR = path.join(process.cwd(), "content", "blog"); // [!code highlight]

/** Return metadata + raw MDX for every published post, sorted newest-first. */
export function getAllPosts(): BlogPost[] {
  const files = fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith(".mdx"));

  return files
    .map((filename) => {
      const slug = filename.replace(/\.mdx$/, "");
      const raw = fs.readFileSync(path.join(POSTS_DIR, filename), "utf-8");
      const { data, content } = matter(raw);

      return {
        slug,
        frontmatter: data as BlogFrontmatter, // [!code highlight]
        content,
        readingTime: readingTime(content).text,
      };
    })
    .sort(
      (a, b) =>
        new Date(b.frontmatter.publishedAt).getTime() -
        new Date(a.frontmatter.publishedAt).getTime()
    );
}

/** Return a single post by slug, or null if it doesn't exist. */
export function getPostBySlug(slug: string): BlogPost | null {
  const filePath = path.join(POSTS_DIR, `${slug}.mdx`);
  if (!fs.existsSync(filePath)) return null; // [!code highlight]

  const raw = fs.readFileSync(filePath, "utf-8");
  const { data, content } = matter(raw);

  return {
    slug,
    frontmatter: data as BlogFrontmatter,
    content,
    readingTime: readingTime(content).text,
  };
}
```

`getPostBySlug` returns `null` instead of throwing — the caller decides whether a missing post is a 404 or an error. We're reading files synchronously because this runs server-side during static generation; there's no event loop to block, and `fs.readFileSync` is actually faster than its async counterpart for small files.

## Blog listing page

The listing page is an async server component. No `"use client"`, no `useEffect`, no loading states. It calls `getAllPosts()` at render time, and because Next.js statically generates App Router pages by default, this runs once at build time and outputs pure HTML.

```tsx title="app/blog/page.tsx"
import Link from "next/link";
import { getAllPosts } from "@/lib/content";

export const metadata = {
  title: "Blog",
  description: "Articles on web development, tooling, and building in public.",
};

export default function BlogPage() {
  const posts = getAllPosts(); // [!code highlight]

  return (
    <main className="mx-auto max-w-2xl px-6 py-16">
      <h1 className="mb-8 text-3xl font-bold tracking-tight">Blog</h1>
      <div className="flex flex-col gap-6">
        {posts.map((post) => (
          <Link
            key={post.slug}
            href={`/blog/${post.slug}`}
            className="group block rounded-lg border p-6 transition-colors
                       hover:border-neutral-400 dark:hover:border-neutral-600"
          >
            <div className="flex items-baseline justify-between gap-4">
              <h2 className="font-semibold group-hover:underline">
                {post.frontmatter.title}
              </h2>
              <time
                dateTime={post.frontmatter.publishedAt}
                className="shrink-0 text-sm text-neutral-500"
              >
                {new Date(post.frontmatter.publishedAt).toLocaleDateString(
                  "en",
                  { year: "numeric", month: "short", day: "numeric" }
                )}
              </time>
            </div>
            <p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
              {post.frontmatter.description}
            </p>
            <span className="mt-3 block text-xs text-neutral-400">
              {post.readingTime}
            </span>
          </Link>
        ))}
      </div>
    </main>
  );
}
```

No data-fetching function to export, no serialization boundary to worry about, no hydration mismatch to debug. The component calls a function, gets data, renders JSX. This is what the App Router was designed for.

If you've used `getStaticProps` in the Pages Router, this is the equivalent — except there's no props object, no serialization boundary, and no separate data-fetching layer. It's a better model once you stop looking for the hooks you're used to.

## Article page

The dynamic route needs three things: `generateStaticParams` to tell Next.js which slugs exist at build time, `generateMetadata` for SEO, and the page component itself that renders the MDX.

```tsx title="app/blog/[slug]/page.tsx"
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc"; // [!code highlight]
import { getAllPosts, getPostBySlug } from "@/lib/content";

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

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

export async function generateMetadata({ params }: PageProps) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) return {};

  return {
    title: post.frontmatter.title,
    description: post.frontmatter.description,
    openGraph: {
      title: post.frontmatter.title,
      description: post.frontmatter.description,
      images: [post.frontmatter.image],
    },
  };
}

export default async function ArticlePage({ params }: PageProps) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) notFound(); // [!code highlight]

  return (
    <main className="mx-auto max-w-2xl px-6 py-16">
      <header className="mb-10">
        <h1 className="text-3xl font-bold tracking-tight">
          {post.frontmatter.title}
        </h1>
        <div className="mt-3 flex items-center gap-3 text-sm text-neutral-500">
          <time dateTime={post.frontmatter.publishedAt}>
            {new Date(post.frontmatter.publishedAt).toLocaleDateString("en", {
              year: "numeric",
              month: "long",
              day: "numeric",
            })}
          </time>
          <span>&middot;</span>
          <span>{post.readingTime}</span>
        </div>
      </header>
      <article className="prose dark:prose-invert max-w-none"> // [!code highlight]
        <MDXRemote source={post.content} />
      </article>
    </main>
  );
}
```

The import is `next-mdx-remote/rsc` — not the default export. This tripped me up the first time. The default `next-mdx-remote` export is designed for the Pages Router: it serializes content in `getStaticProps` and hydrates it on the client.

The `/rsc` export skips all of that — it compiles MDX on the server as part of the React render tree, which means zero client-side JavaScript for your content. The entire article ships as static HTML.

Also note the `params` type: `Promise<{ slug: string }>`. Next.js 15+ made `params` asynchronous in layouts and pages, so you need to `await` it. If you forget, TypeScript will catch it — but the error message is confusing if you don't know what changed.

> If `getPostBySlug` returns `null`, calling `notFound()` triggers Next.js's built-in 404 page. No try-catch, no error boundaries, no conditional renders.

## Custom components

This is the real payoff of MDX over plain markdown. You can pass React components into the renderer and use them directly in your `.mdx` files. A callout box, a responsive image with blur placeholder, a styled link — anything you can build in React, you can embed in your writing.

### The components map

Create a file that maps component names to their implementations. This is what `MDXRemote` uses to resolve JSX tags in your content.

```tsx title="lib/mdx-components.tsx"
import Image from "next/image";
import type { MDXComponents } from "mdx/types";
import { Callout } from "@/components/callout";

export const components: MDXComponents = {
  Callout,
  Image: (props: React.ComponentProps<typeof Image>) => (
    <Image
      className="rounded-lg"
      sizes="(max-width: 768px) 100vw, 672px"
      {...props}
    />
  ),
  a: ({ href, children, ...props }) => ( // [!code highlight]
    <a
      href={href}
      target={href?.startsWith("http") ? "_blank" : undefined}
      rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
      {...props}
    >
      {children}
    </a>
  ),
};
```

### A Callout component

This is the component I use most. Tip boxes, warnings, info blocks — one component with a `type` prop.

```tsx title="components/callout.tsx"
import type { ReactNode } from "react";

interface CalloutProps {
  type?: "info" | "warning" | "tip";
  title?: string;
  children: ReactNode;
}

const styles = {
  info: "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
  warning: "border-amber-500 bg-amber-50 dark:bg-amber-950/30",
  tip: "border-purple-500 bg-purple-50 dark:bg-purple-950/30", // [!code highlight]
};

export function Callout({ type = "info", title, children }: CalloutProps) {
  return (
    <div className={`my-6 rounded-lg border-l-4 p-4 ${styles[type]}`}>
      {title && <p className="mb-1 font-semibold">{title}</p>}
      <div className="text-sm leading-relaxed">{children}</div>
    </div>
  );
}
```

### Wiring it up

Pass the components map to `MDXRemote` in your article page:

```tsx title="app/blog/[slug]/page.tsx"
import { components } from "@/lib/mdx-components";

// inside the page component:
<article className="prose dark:prose-invert max-w-none">
  <MDXRemote source={post.content} components={components} /> // [!code highlight]
</article>
```

Now your `.mdx` files can use these components without any imports:

```mdx title="content/blog/my-first-post.mdx"
## Setting up the project

<Callout type="tip" title="Before you start">
Make sure you have Node.js 18+ and pnpm installed.
</Callout>

Here's what the dashboard looks like:

<Image src="/blog/dashboard.png" alt="Dashboard screenshot" width={1200} height={630} />
```

That's the entire value proposition of MDX in one example. Your content is markdown. Your interactive elements are React. They coexist in the same file, version-controlled in the same repo.

You can add as many components as you want — charts, embedded demos, interactive quizzes — without any changes to your rendering pipeline. Add a component, add a key.

## Styling the prose

You've done the hard part — content loads, MDX compiles, components resolve. But if you preview the page right now, you'll notice the rendered HTML looks terrible. Headings have no margins, paragraphs run together, lists have no bullets. That's because Tailwind's preflight strips all default browser styles. The `@tailwindcss/typography` plugin adds them back with a single class.

```bash title="Terminal"
pnpm add @tailwindcss/typography
```

Add the plugin import to your global CSS file (Tailwind v4 uses CSS-based configuration):

```css title="app/globals.css"
@import "tailwindcss";
@plugin "@tailwindcss/typography"; /* [!code highlight] */
```

Wrap your MDX output in a `prose` container. Add `dark:prose-invert` so it respects dark mode.

```tsx title="app/blog/[slug]/page.tsx"
<article className="prose dark:prose-invert max-w-none"> /* [!code highlight] */
  <MDXRemote source={post.content} components={components} />
</article>
```

That single `prose` class applies typographic defaults to every HTML element inside it — headings get proper sizing and spacing, paragraphs get readable line heights, lists get bullets, code blocks get backgrounds, links get underlines. It transforms raw HTML into something that actually looks like a blog post.

The `max-w-none` override lets the container width be controlled by the parent instead of typography's default `65ch`.

> The difference between a blog that looks amateur and one that looks professional is almost entirely typography. `@tailwindcss/typography` gets you 90% of the way there with zero custom CSS.

---

So here's what you've built: a blog where every post is a `.mdx` file in your repo, parsed by `gray-matter`, compiled by `next-mdx-remote`, rendered as a React Server Component, and deployed as static HTML. No database. No CMS. No vendor to migrate away from when they inevitably change their pricing or shut down their API.

Content in git means version history, branch-based drafts, and PR reviews for your writing. Rendering in React means you can embed any component you can build. Static HTML output means it loads fast everywhere and costs almost nothing to host.

From here, the natural next steps are syntax highlighting (look at [rehype-shiki](https://github.com/shikijs/shiki) — it's what I use), an [RSS feed](/blog/rss.xml) for subscribers, and maybe full-text search if your post count warrants it.

If you're still getting your dev environment sorted, [my terminal setup guide](/blog/terminal-first-dev-setup) covers the tooling side. And if you're earlier in the journey — still figuring out whether to even learn to code — I wrote about [what I wish I'd known](/blog/what-i-wish-i-knew-before-learning-to-code) before starting.

But the foundation is solid. Every piece of this system is a plain function or a React component — no framework magic, no generated code, no build step you don't understand. When something breaks (and it will), you'll know exactly where to look. That's the whole point.

You own your content. Ship it.

## Frequently asked questions

### Do I need a database to build a blog with Next.js and MDX?

No. Each post is a .mdx file in your repo, parsed at build time with gray-matter — the filesystem is the database. No Postgres, no CMS, and every post deploys as static HTML.

### What's the difference between Markdown and MDX?

MDX is Markdown that can import and render React components inline. You keep Markdown's simple prose syntax but can drop in interactive components like callouts, steps, or charts where plain Markdown falls short.

### How do I add frontmatter metadata to MDX posts?

Put a YAML block at the top of the file between fence markers (title, date, tags), then parse it with gray-matter. Validate it with a Zod schema so a malformed post fails the build instead of shipping broken.

### Is a Next.js MDX blog good for SEO?

Yes. Posts are statically generated as fast, crawlable HTML, and you can emit per-post metadata, canonical URLs, and BlogPosting JSON-LD from the same frontmatter — structured content for search engines and AI assistants.
