All posts

Building a blog with MDX, Next.js and Tailwind: why markdown is never too much

While writing a post packed with interactive components, I noticed I was spending more time on the post's code than on the text. That's when I decided to rebuild the blog with MDX. In this post, I walk through how to put together a blog with Next.js, MDX and Tailwind.

5 min read

On this page

While writing a post packed with interactive components, I noticed I was spending more time on the components' code than on the actual text. That got me wondering: "isn't there a better way to mix code and content?"

After trying a few options, I decided to rebuild the blog with MDX. Fitting it into my stack took more work than I expected. I banged my head against a few config issues, but in the end I got to the version you're reading right now.

In this post, I'll go through the whole process, from the initial setup to the final tweaks, so you can build your own blog with Next.js, MDX and Tailwind CSS without the same headaches. But first, why MDX?

Why MDX?

If you've ever written in Markdown, you know how much easier it makes things. The syntax is simple, and you can format text without touching HTML or fighting a visual editor full of bells and whistles. Now imagine mixing Markdown with React components. That's exactly what MDX does.

MDX (Markdown + JSX) lets you write posts in Markdown and drop React components right in the middle of the text. Besides paragraphs, lists and images, you can add buttons, animated charts, alerts or any other component you like. That flexibility was what I needed to explain things better and make the posts more fun to read.

Setting up the project

Let's start by creating a Next.js project. For MDX support, we'll use @next/mdx, the official Next.js package for it.

Creating the project

In your terminal, run:

bash
npx create-next-app@latest
shell
What is your project named? mdx-blog
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like your code inside a `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to use Turbopack for `next dev`? No
Would you like to customize the import alias (`@/*` by default)? No

Now a few dependencies for MDX:

bash
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx gray-matter remark-frontmatter remark-mdx-frontmatter

Why so many dependencies? (damn you, JavaScript ๐Ÿ˜’)

  • @next/mdx, @mdx-js/loader, @mdx-js/react, @types/mdx: let Next.js handle MDX files, which mix Markdown and JSX.
  • gray-matter: pulls metadata out of Markdown/MDX files from the frontmatter, a YAML (or JSON/TOML) block at the top of the file.
  • remark-frontmatter, remark-mdx-frontmatter: make the MDX parser recognize the frontmatter block and turn that data into an object you can access inside the MDX.

Hooking MDX into Next.js

With the project created, let's plug MDX into Next.js:

next.config.ts
import createMDX from "@next/mdx";
import remarkFrontmatter from "remark-frontmatter";
import remarkMDXFrontmatter from "remark-mdx-frontmatter";

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

const withMDX = createMDX({
options: {
remarkPlugins: [remarkFrontmatter, remarkMDXFrontmatter],
},
});

export default withMDX(nextConfig);

Here's what this code does:

  • Adds MDX support to Next.js through @next/mdx.
  • Enables frontmatter parsing with the remark-frontmatter and remark-mdx-frontmatter plugins.
  • Sets the page extensions (.mdx, .md, .js, .jsx, .ts, .tsx) so files in those formats can be treated as pages by Next.js.

Dynamic post pages

With everything set up, we can start structuring the blog. We'll use this folder layout:

text
โ”ฃ ๐Ÿ“‚ public/
โ”ฃ ๐Ÿ“‚ posts/ # posts and pages written in MDX
โ”ƒ โ”ฃ example.mdx
โ”ฃ ๐Ÿ“‚ src/
โ”ƒ โ”ฃ ๐Ÿ“‚ app/
โ”ƒ โ”ƒ โ”ฃ ๐Ÿ“‚ blog/ # main blog page (post list)
โ”ƒ โ”ƒ โ”ƒ โ”ฃ ๐Ÿ“‚ [slug]/ # dynamic route that renders each post
โ”ƒ โ”ƒ โ”ƒ โ”ƒ โ”ฃ page.tsx
โ”ƒ โ”ƒ โ”ƒ โ”ฃ page.tsx
โ”ƒ โ”ƒ โ”ฃ globals.css
โ”ƒ โ”ƒ โ”ฃ layout.tsx
โ”ƒ โ”ƒ โ”ฃ page.tsx
โ”ƒ โ”ฃ ๐Ÿ“‚ components/
โ”ƒ โ”ฃ ๐Ÿ“‚ widgets/ # components used inside MDX
โ”ƒ โ”ฃ ๐Ÿ“‚ utils/ # helper functions, like frontmatter parsing
โ”ƒ โ”ฃ ๐Ÿ“‚ lib/ # MDX config, file loading, etc.
โ”ƒ โ”ฃ mdx-components.tsx # more on this one later
โ”ฃ .gitignore # files and folders ignored by git
โ”ฃ package.json # project dependencies and scripts
โ”ฃ next.config.ts # Next.js config, including MDX support
โ”ฃ README.md # project docs

What's [slug]?

In Next.js, [slug] is a dynamic route, which generates pages automatically from an identifier. In our case, the identifier is the MDX file name. Instead of creating a page by hand for every post, Next.js loads and renders the right content based on the URL.

In the page.tsx file of the [slug] route, we'll create a component that grabs the post name from the URL (for example, /blog/example-post), finds the matching file (example-post.mdx) and renders it.

The code looks roughly like this:

src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
try {
// Dynamically import the post based on the URL slug
const { default: Post } = await import(
`../../../../posts/${params.slug}.mdx`
);

return (
<article>
<div>
<span>{/* Title */}</span>
<span>{/* PublishedOn */}</span>
</div>

<Post />
</article>
);
} catch (error) {
// If the file doesn't exist, show the 404 page
notFound();
}
}

This way, every new post you drop into posts/ shows up at /blog/post-name, with no extra setup.

There's still one thing missing: how do we get to the posts' metadata? The page needs to show at least the title and publish date, and that information lives in the frontmatter.

Creating posts-helpers.ts

To keep the code organized, let's create a posts-helpers.ts file inside the utils/ folder. It'll hold the helper functions for working with posts, like listing all of them and loading a specific one.

src/utils/posts-helpers.ts
import fs from "fs/promises";
import path from "path";
import matter from "gray-matter";

type BlogPost = {
slug: string;
title: string;
abstract: string;
publishedOn: string; // Could be `Date` if you'd rather work with date objects
};

/**
* Returns the list of blog posts sorted by publish date (newest first).
*/
export async function getBlogPostList(): Promise<BlogPost[]> {
const fileNames = await readDirectory("posts"); // Lists the files in `posts/`
const blogPosts: BlogPost[] = [];

for (const fileName of fileNames) {
const rawContent = await readFile(`posts/${fileName}`);
const { data: frontmatter } = matter(rawContent); // Extracts the metadata (frontmatter)

blogPosts.push({
...(frontmatter as Omit<BlogPost, "slug">),
slug: fileName.replace(".mdx", ""), // The slug is the file name without the extension
});
}

// Sorts posts from newest to oldest
return blogPosts.sort((a, b) => (a.publishedOn < b.publishedOn ? 1 : -1));
}

/**
* Loads the content of a specific post from its `slug`.
*/
export async function loadBlogPost(slug: string) {
const rawContent = await readFile(`posts/${slug}.mdx`);
const { data: frontmatter, content } = matter(rawContent);

return { frontmatter, content };
}

/**
* Reads a file inside the project.
*/
async function readFile(localPath: string) {
return fs.readFile(path.join(process.cwd(), localPath), "utf8");
}

/**
* Reads the file names in a directory.
*/
async function readDirectory(localPath: string) {
return fs.readdir(path.join(process.cwd(), localPath));
}

Sweet! With the helpers ready, we can go back to the dynamic [slug] page and have it load the post's metadata.

src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { loadBlogPost } from "@/utils/posts-helpers";

export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
try {
const { default: Post } = await import(
`../../../../posts/${params.slug}.mdx`
);
const { frontmatter } = await loadBlogPost(params.slug);

return (
<article>
<div>
<span>{frontmatter.title}</span>
<span>{frontmatter.publishedOn}</span>
</div>

<Post />
</article>
);
} catch (error) {
notFound();
}
}

With the post page working, the next step is listing the posts on the main blog page.

Listing posts at /blog

Every blog needs a page that lists its posts, so readers can see what's there and pick something to read. Let's build that list at the /blog route:

src/app/blog/page.tsx
import Link from "next/link";
import { getBlogPostList } from "@/utils/posts-helpers";

export default async function BlogPage() {
const blogPosts = await getBlogPostList();

return (
<div>
<h1>My MDX Blog</h1>

<div>
{blogPosts.map(({ slug, title, abstract, publishedOn }) => (
<Link key={slug} href={`/blog/${slug}`}>
<div>
<h2>{title}</h2>
<time dateTime={publishedOn}>
{new Date(publishedOn).toLocaleDateString()}
</time>
</div>

<p>{abstract}</p>
<span>Keep reading โ†’</span>
</Link>
))}
</div>
</div>
);
}

What about Tailwind?

To get Tailwind working properly inside MDX files, we need a few more tweaks.

  1. Tailwind has an official plugin called @tailwindcss/typography that improves how text is rendered. Let's install it:
bash
npm install @tailwindcss/typography
  1. Now add the plugin to globals.css:
src/app/globals.css
@import "tailwindcss";

@plugin "@tailwindcss/typography";
  1. To make the posts look good, let's create a layout component called MDXLayout inside the components folder:
src/components/mdx-layout.tsx
interface MDXLayoutProps {
children: React.ReactNode;
}

export default function MDXLayout({ children }: MDXLayoutProps) {
return (
<section className="prose prose-a:text-white prose-headings:text-start prose-strong:text-white text-text-dark prose-headings:mt-8 prose-headings:font-semibold prose-headings:text-text-dark prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg dark:prose-headings:text-white">
{children}
</section>
);
}
  1. Finally, wrap the post in MDXLayout so Tailwind can style the content:
src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import MDXLayout from "@/components/mdx-layout";
import { loadBlogPost } from "@/utils/posts-helpers";

export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
try {
const { default: Post } = await import(
`../../../../posts/${params.slug}.mdx`
);
const { frontmatter } = await loadBlogPost(params.slug);

return (
<article>
<div>
<span>{frontmatter.title}</span>
<span>{frontmatter.publishedOn}</span>
</div>

<MDXLayout>
<Post />
</MDXLayout>
</article>
);
} catch (error) {
notFound();
}
}

React components inside MDX

The big win with MDX is being able to use React components inside your posts. For that to work in Next.js, we need a special file called mdx-components.tsx, which defines which components are available in MDX files.

src/mdx-components.tsx
import type { MDXComponents } from "mdx/types";

import ChromaticCircle from "@/widgets/chromatic-circle";

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
ChromaticCircle, // Makes the component available in MDX files
};
}

With the component registered, you can use it in an MDX post:

posts/example.mdx
## The color wheel

If you work with color ...

<ChromaticCircle type="selector" />

One detail: Next.js only finds mdx-components.tsx if it sits at the root of the src/ folder. The structure looks like this:

text
๐Ÿ“‚ src/
โ”ฃ ๐Ÿ“‚ components/
โ”ฃ ๐Ÿ“‚ widgets/ <-- components for MDX
โ”ƒ โ”— ๐Ÿ“œ chromatic-circle.tsx
โ”— ๐Ÿ“œ mdx-components.tsx

Next steps

From here, just create your .mdx files in the posts/ folder and start writing. What we built is the skeleton: a list page, a post page and component support. Styling, tags, an RSS feed and everything else are up to you.

If you build yours, send me the link. I want to see what you end up putting in the middle of your text.