November 29, 2024
Building a Next.js Blog with MDX and TypeScript: A Complete Guide
Learn how to build a lightweight and customizable blog with Next.js and MDX. A step-by-step guide covering everything from dynamic fetching to custom components.
If you're short on time, you can find the full code on GitHub.
Introduction
Building an MDX blog with Next.js is a popular choice for many websites, whether it's for a portfolio or a SaaS platform. While tools like Sanity, TinaCMS, or Contentful are great, they often offer more complexity than necessary for a simple blog.
Next.js combined with MDX allows you to create a lightweight, flexible blog without the extra overhead.
In this guide, we'll cover:
- Dynamically fetching blog posts stored locally.
- Rendering MDX content in Next.js pages.
- Adding custom React components to your blog posts.
- Customizing the styles of your MDX content.
By the end, you'll have a fully functional MDX blog in Next.js, ready to showcase your content with ease.
What is MDX and Why Use It for Your Blog?
MDX is a powerful superset of Markdown that allows you to write JSX directly inside your markdown files. This means you can seamlessly import and use React components within your content, making it incredibly flexible and interactive.
For creating blog posts, MDX is an excellent choice because it combines the simplicity of Markdown with the power of React. You can write standard markdown content, but if you need advanced features, MDX gives you the tools to implement them effortlessly.
Why Use MDX for Your Blog?
Here are some key benefits of using MDX for blogging:
- Ease of Use: Writing a blog post with MDX is as straightforward as using Markdown, making it accessible even to non-technical users.
- Flexibility: Add any React component to your blog post, such as charts, diagrams, or call-to-action elements, to make your content more engaging.
- Content Ownership: Store your MDX files locally without needing a database or external CMS. You retain full control over your content.
- Version Control: If your project is hosted on GitHub, you can track changes to your content and easily revert to previous versions if needed.
With these features, MDX is a fantastic option for building a blog that's simple, scalable, and highly customizable.
Step 1: Install and Configure Your Project
Let's start by setting up a new Next.js project and configuring it to work with MDX.
Create a New Next.js Project
Run the following command to create a new Next.js application:
npx create-next-app@latest
You'll be prompted to name your project. Enter a name, then press Enter to select the default options for each prompt. Once the setup is complete, you should see the new project structure in your directory.
Install MDX Dependencies
Next, we'll install the required dependencies for MDX support. Run the following command using pnpm
(or your preferred package manager):
pnpm add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
Configure MDX in next.config.mjs
Now, update your next.config.mjs
file to enable MDX support. Replace the contents of the file with the following configuration:
import createMDX from "@next/mdx";/** @type {import('next').NextConfig} */const nextConfig = { // Configure `pageExtensions` to include markdown and MDX files pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], // Optionally, add any other Next.js config below};const withMDX = createMDX({ // Add markdown plugins here, as desired});// Merge MDX config with Next.js configexport default withMDX(nextConfig);
This setup ensures that Next.js recognizes .md
and .mdx
files as valid pages.
Define Custom MDX Components
At the root of your project (alongside the app
directory), create a new file named mdx-components.tsx
. This file will define custom React components that can be used in your MDX content. For now, we'll use the default configuration:
import type { MDXComponents } from "mdx/types";export function useMDXComponents(components: MDXComponents): MDXComponents { return { ...components, };}
This basic setup allows you to extend or customize MDX components later.
Ready to Render MDX
With the installation and configuration done, you're now ready to start rendering MDX content in your Next.js project. Let's move on to creating and displaying your first blog post.
Step 2: Render Your First Blog Post
Now that MDX is configured, let's render your first blog post. There are two ways to render MDX files in Next.js:
Option 1: Directly Render .mdx
Files as Pages
You can create .mdx
files directly in your app
directory, and they will be treated as regular Next.js pages. For example, you could use the following structure:
next-mdx-blog├── app│ └── blog│ └── my-blog-post│ └── page.mdx├── mdx-components.tsx└── package.json
In this setup, page.mdx
will be rendered as a normal Next.js page, accessible at http://localhost:3000/blog/my-blog-post
. This method works well for simple use cases.
Option 2: Dynamic Blog Posts with Slugs
For a blog, we often want dynamic pages with unique URLs based on the post's filename. To achieve this, we'll use the following structure:
next-mdx-blog├── app│ └── blog│ └── [slug]│ └── page.tsx├── private│ └── posts│ └── hello-world.mdx├── mdx-components.tsx└── package.json
This approach allows us to dynamically load and render blog posts based on their filenames.
Create Your First Blog Post
-
Add a Blog Post
In the root of your project, create a
private/posts
folder. Inside, create a new file namedhello-world.mdx
with the following content:# Hello WorldThis is my first post!
This will be your first blog post.
-
Create a Dynamic Blog Page
In the
app/blog/[slug]
directory, create apage.tsx
file with the following content:import Post from "@/private/posts/hello-world.mdx";export default function BlogPostPage() { return <Post />;}
Here, the
page.tsx
file imports the MDX file and renders it as a React component. -
Run Your Development Server
Start your development server:
pnpm dev
-
View Your Blog Post
Visit
http://localhost:3000/blog/hello-world
in your browser. You should see your blog post rendered on the page. Pretty cool, right?
Step 3: Render Dynamic Blog Posts
So far, we've rendered a single blog post. But manually creating a page for each post isn't practical for a real blog. Let's make the process dynamic by adding multiple posts and metadata.
Populating the Blog Posts
-
Add More Blog Posts
To populate your blog with content:
- Create a few
.mdx
files in the/private/posts
folder. - You can write your own content, ask ChatGPT to generate placeholder posts, or use pre-existing examples from the GitHub repository.
For example, you might create the following files:
/private/posts/first-post.mdx
/private/posts/second-post.mdx
/private/posts/third-post.mdx
- Create a few
-
Add Metadata
At the top of each
.mdx
file, include a metadata object in the following structure:export const metadata = { title: "Some title", featuredImage: "path-to-image", publishDate: "2024-11-20", lastModified: "2024-11-20", author: { name: "Author Name", bio: "A short bio about the author", avatar: "path-to-avatar-image", }, excerpt: "Short description of the post, to be displayed on post cards", tags: ["Array", "of", "tags"], seo: { metaTitle: "A Title for SEO, can be the same as your post title", metaDescription: "Description for SEO, can be the same as excerpt", },};;
This metadata object provides essential information about each blog post, such as its title, author details, tags, and SEO fields.
-
Example Blog Post
Here's what a complete
.mdx
file might look like:export const metadata = { title: "Build Stunning Landing Pages in Minutes with Ready.js", featuredImage: "/uploads/posts/landing-page.jpg", publishDate: "2024-11-20", lastModified: "2024-11-20", author: { name: "John Doe", bio: "Frontend Developer and Creator of Ready.js Boilerplate", avatar: "/uploads/authors/john-doe.jpg", }, excerpt: "Ready.js offers 16 prebuilt landing page sections to kickstart your web projects.", tags: ["landing-pages", "nextjs", "ready.js", "developer-tools"], seo: { metaTitle: "Build Stunning Landing Pages in Minutes with Ready.js", metaDescription: "Ready.js includes prebuilt landing page sections to help developers launch quickly and efficiently.", },};# Build Stunning Landing Pages in Minutes with Ready.jsReady.js comes with 16 prebuilt landing page sections designed to help you launch faster. From hero sections to testimonials and CTAs, these components are fully customizable and easy to use. Perfect for developers looking to get their projects off the ground quickly.## Why Use Ready.js for Landing Pages?Building landing pages from scratch can be time-consuming and tedious. With Ready.js, you can skip the initial setup and focus on customizing the content to fit your brand. Whether you're a solo developer or part of a team, Ready.js makes it easy to create stunning landing pages that convert.## How to Get StartedTo start using Ready.js for your landing pages, simply install the package via npm or yarn. Then, import the components you need and start building. With Ready.js, you can create beautiful landing pages in minutes, not hours.
-
Organize Images
- Add author avatars to the
/public/uploads/authors/
folder. For example:/public/uploads/authors/avatar.jpg
- Add featured images for your blog posts to
/public/uploads/posts/
. For example:/public/uploads/posts/landing-page.jpg
Ensure the paths in your metadata (e.g.,
featuredImage
andavatar
) point to the correct locations. - Add author avatars to the
-
Verify Folder Structure
After adding your posts and images, your folder structure should look something like this:
Once this is set up, you're ready to move on.
Create a Fetch Function
Now, let's enhance our blog post page to fetch posts dynamically based on their slug.
First, we need a function to load a blog post by its slug. Create a new file at app/blog/[slug]/_queries/get-post.tsx
and add the following code:
import { promises as fs } from "fs";import { MDXContent } from "mdx/types";import path from "path";type Author = { name: string; bio: string; avatar: string;};type SEO = { metaTitle: string; metaDescription: string;};export type PostMetadata = { title: string; featuredImage?: string; publishDate: string; lastModified?: string; author: Author; excerpt: string; tags: string[]; seo: SEO;};export type Post = PostMetadata & { slug: string; content: MDXContent;};export async function getPost(slug: string): Promise<Post | null> { try { const post = await import(`@/private/posts/${slug}.mdx`); return { slug, ...post.metadata, content: post.default, } as Post; } catch (error) { console.error("Error loading blog post:", error); return null; }}
What's Happening Here?
- We define several types to structure our blog data:
Author
for author details.SEO
for SEO metadata.PostMetadata
for post-specific metadata.Post
, which includes metadata, the slug, and the MDX content.
- The
getPost
function takes a slug as input and imports the corresponding.mdx
file from the/private/posts
folder. - The function returns the post's metadata, slug, and content as an object.
Update the Blog Post Page
Now, update the page at app/blog/[slug]/page.tsx
to dynamically fetch and display blog content:
import { notFound } from "next/navigation";import { getPost } from "./_queries/get-post";export default async function BlogPostPage({ params,}: { params: { slug: string };}) { const { slug } = await params; const post = await getPost(slug); if (!post) { notFound(); } const Content = post.content; return <Content />;}
What's Happening Here?
- The
params
object is used to extract the slug from the URL. - The
getPost
function is called to fetch the corresponding blog post. If the post is not found, thenotFound()
function renders a 404 page. - The MDX content is dynamically rendered as a React component.
Test Your Setup
- Start the development server:
pnpm dev
. - Visit
http://localhost:3000/blog/your-blog-post
, replacingyour-blog-post
with the name of one of your MDX files (e.g.,hello-world
). - You should see the content of your blog post rendered on the page.
So far, it's not styled, but the dynamic fetching works as expected. Let's improve the appearance in the next steps!
Add Styling to Your Blog Post Page
Let's add some basic styling to make your MDX content visually appealing using Tailwind CSS.
Tailwind provides a typography plugin that simplifies styling for markdown content, which is useful for rendering MDX content.
-
Install the plugin:
pnpm add -D @tailwindcss/typography
-
Update your
tailwind.config.ts
file located at the root of your project. Add the plugin as shown below:import typographyPlugin from "@tailwindcss/typography"; import type { Config } from "tailwindcss";export default { content: [ "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { background: "var(--background)", foreground: "var(--foreground)", }, }, }, plugins: [typographyPlugin], } satisfies Config;
- The
@tailwindcss/typography
plugin provides ready-to-use classes likeprose
for styling HTML content such as headings, lists, and paragraphs.
- The
Now, apply the prose
class to style the MDX content:
-
Open your
app/blog/[slug]/page.tsx
file. -
Wrap the MDX content in a
section
element with Tailwind'sprose
class:import { notFound } from "next/navigation";import { getPost } from "./_queries/get-post";export default async function BlogPostPage({ params,}: { params: { slug: string };}) { const { slug } = await params; const post = await getPost(slug); if (!post) { notFound(); } const Content = post.content; return ( <section className="prose prose-lg prose-li:mb-2 prose-ul:mt-4 hover:prose-a:text-primary hover:prose-a:decoration-primary prose-a:underline prose-p:mt-2 prose-p:mb-4 max-w-none dark:prose-invert prose-headings:font-bold prose-headings:mt-6 prose-headings:mb-2"> <Content /> </section> ); }
What's Happening Here?
- The
prose
class is applied to style all HTML elements within the section. - Customizations like
prose-headings:font-bold
orprose-li:mb-2
allow you to target specific HTML elements (e.g.,h1
,li
) and apply Tailwind styles.
For example:
- Target headings: Use
prose-headings:[your-class]
to style all heading tags (h1
,h2
,h3
, etc.). - Target paragraphs: Use
prose-p:[your-class]
forp
tags. - Target links: Use
hover:prose-a:[your-class]
to customize link styles.
Before we move on, you can verify that your styling works:
- Start the development server:
pnpm dev
. - Visit a blog post (e.g.,
http://localhost:3000/blog/your-blog-post
). - The MDX content should now be styled with a clean and readable design.
You're now equipped with styled markdown content, making your blog posts visually appealing!
Enhance Your Blog Post Page
Now, let's finish the blog post page by adding metadata like the featured image, publication date, and author information to create a polished layout.
Replace the existing code in your app/blog/[slug]/page.tsx
file with the following:
import Image from "next/image";import { notFound } from "next/navigation";import { getPost } from "./_queries/get-post";export default async function BlogPostPage({ params,}: { params: { slug: string };}) { const { slug } = await params; const post = await getPost(slug); if (!post) { notFound(); } const Content = post.content; return ( <article className="mx-auto py-20 max-w-screen-md"> <header className="text-center"> <p className="mb-8 text-sm text-muted-foreground"> {new Date(post.publishDate).toLocaleDateString([], { day: "numeric", month: "long", year: "numeric", })} </p> <h1 className="text-4xl">{post.title}</h1> <p className="mb-4 text-lg text-muted-foreground"> {post.excerpt} </p> {post.featuredImage && ( <div className="relative mb-8 h-64 w-full md:h-[500px]"> <Image src={post.featuredImage} alt={post.title} fill className="rounded-lg object-cover" priority /> </div> )} </header> <section className="prose prose-lg prose-li:mb-2 prose-ul:mt-4 hover:prose-a:text-primary hover:prose-a:decoration-primary prose-a:underline prose-p:mt-2 prose-p:mb-4 max-w-none dark:prose-invert prose-headings:font-bold prose-headings:mt-6 prose-headings:mb-2"> <Content /> </section> <footer className="mt-16"> <div className="flex items-center gap-4 rounded-lg border p-6"> {post.author.avatar && ( <div className="relative h-16 w-16 flex-shrink-0"> <Image src={post.author.avatar} alt={post.author.name} fill className="rounded-full object-cover" /> </div> )} <div> <h3 className="font-semibold">{post.author.name}</h3> {post.author.bio && ( <p className="text-sm text-muted-foreground">{post.author.bio}</p> )} </div> </div> </footer> </article> );}
What's Happening Here?
- Header:
- Displays the publication date, title, excerpt, and the featured image of the blog post.
- The date is formatted using
toLocaleDateString
for better readability. - If the
featuredImage
exists, it is displayed usingnext/image
with responsive and optimized settings.
- Content:
- The MDX content is rendered as before, wrapped in a
section
with Tailwind'sprose
classes for styling.
- The MDX content is rendered as before, wrapped in a
- Footer:
- Adds a card-like layout to show the author's avatar, name, and bio, using the metadata retrieved from the
getPost
function.
- Adds a card-like layout to show the author's avatar, name, and bio, using the metadata retrieved from the
Verify the Layout
- Start your development server:
pnpm dev
. - Visit a blog post (e.g.,
http://localhost:3000/blog/your-blog-post
). - You should see something like this:
This step adds significant polish to your blog post page. The layout is now visually appealing and ready for further customization if needed.
Step 4: Creating the Post List Page
Now that your blog posts are rendering dynamically, let's build a page to display all your posts in a visually appealing list.
Fetching All Blog Posts
The first step is to create a function that retrieves all your blog posts along with their metadata. This will allow us to display a preview of each post on the list page.
- In the
app/blog
directory, create a_queries
folder. - Inside
_queries
, create a file namedget-all-posts.tsx
with the following code:
import { readdir } from "fs/promises";import path from "path";import { Post } from "../[slug]/_queries/get-post";export async function getAllPosts() { const postsPath = path.join(process.cwd(), "private/posts"); // Get all MDX files const files = (await readdir(postsPath, { withFileTypes: true })) .filter((file) => file.isFile() && file.name.endsWith(".mdx")) .map((file) => file.name); // Retrieve metadata from MDX files const posts = await Promise.all( files.map(async (filename) => { const slug = filename.replace(/\.mdx$/, ""); // Remove .mdx extension to get slug const { metadata } = await import(`@/private/posts/${filename}`); return { slug, ...metadata }; }), ); // Sort posts from newest to oldest posts.sort((a, b) => +new Date(b.publishDate) - +new Date(a.publishDate)); return posts as Post[];}
What This Does:
- Retrieves All MDX Files: The function scans the
private/posts
folder and filters for.mdx
files. - Extracts Metadata: For each
.mdx
file, it imports the metadata and associates it with a slug (based on the filename). - Sorts Posts: Posts are sorted by their
publishDate
, ensuring the newest posts appear first. - Returns an Array: You'll get an array of posts, each containing metadata like title, tags, excerpt, and slug.
Creating the Blog List Page
Next, create a page to display your posts.
- In the
app/blog
directory, create apage.tsx
file. - Add the following code:
import Image from "next/image";import Link from "next/link";import { getAllPosts } from "./_queries/get-all-posts";export default async function BlogPage() { const posts = await getAllPosts(); return ( <main className="container mx-auto py-20"> <div className="mb-16 text-center"> <h1 className="text-4xl font-bold">Blog</h1> </div> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {posts.map( ({ slug, title, publishDate, tags, author, excerpt, featuredImage, }) => ( <Link href={`/blog/${slug}`} key={slug} className="group"> <div className="h-full overflow-hidden rounded-md border border-gray-500 transition-shadow duration-300 hover:shadow-lg"> {featuredImage && ( <div className="relative h-48 w-full"> <Image src={featuredImage} alt={title} layout="fill" objectFit="cover" className="transition-transform duration-300 ease-in-out group-hover:scale-105" /> </div> )} <div className="p-4"> <h2 className="mb-2 text-xl font-semibold text-gray-900 group-hover:underline dark:text-white"> {title} </h2> <p className="mb-4 line-clamp-3 text-sm text-gray-500 dark:text-gray-400"> {excerpt} </p> <div className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"> <time dateTime={publishDate}> {new Date(publishDate).toLocaleDateString()} </time> <span>|</span> <span>by {author.name}</span> </div> </div> <div className="p-4 pt-0"> <div className="flex flex-wrap gap-2"> {tags.map((tag) => ( <span key={tag} className="rounded-full bg-blue-50 px-1.5 py-0.5 text-xs text-blue-900" > {tag} </span> ))} </div> </div> </div> </Link> ), )} </div> </main> );}
How It Works:
- Fetch Posts: The
getAllPosts
function retrieves the metadata and slugs for all posts. - Display Cards: Each post is displayed as a card in a responsive grid.
- Interactive Links: Clicking on a card redirects users to the corresponding blog post.
Test Your Blog Page
- Run your development server:
pnpm dev
. - Navigate to
http://localhost:3000/blog
. - You should see a grid of blog post cards, looking like this:
Clicking on a card should take you to the detailed blog post page.
And that's it! You now have a functional and styled blog list page showcasing your posts.
Step 5: Add Custom Components in your MDX
Now that your blog is functional, let's add some interactivity. Blog posts often benefit from rich features like diagrams, comparison charts, or tools. With MDX, you can easily include any React component directly in your post.
In this section, we'll create a simple Call to Action (CTA) component to encourage readers to take action—such as buying a product. A visible and engaging CTA stands out better than a plain link in the text.
Create the CTA Component
- In your
app/blog
directory, create a_components
folder. - Inside
_components
, create a file namedcta-card.tsx
with the following content:
import Link from "next/link";interface CTACardProps { title: string; description: string; buttonText: string; buttonLink: string;}export const CTACard = ({ title, description, buttonText, buttonLink,}: CTACardProps) => { return ( <div className="not-prose my-20 w-full border border-gray-500 rounded-md p-6 flex flex-col items-center"> <div className="text-center text-blue-500 text-3xl font-bold mb-2"> {title} </div> <div className="text-center text-muted-foreground mb-8"> {description} </div> <Link href={buttonLink} className="bg-blue-500 text-white rounded-md px-4 py-2" > {buttonText} </Link> </div> );};
What's Happening Here:
- The
CTACard
component takestitle
,description
,buttonText
, andbuttonLink
as props. This makes it versatile for use in different contexts. - The card is styled using Tailwind classes to make it visually appealing and ensure it stands out in the blog post.
Add the Component to MDX
To use this component in your MDX files, you'll need to add it to your MDX component registry.
- Open your
mdx-components.tsx
file (located at the root of your project). - Update it as follows:
import type { MDXComponents } from "mdx/types";import { CTACard } from "./app/blog/_components/cta-card"; export function useMDXComponents(components: MDXComponents): MDXComponents { return { ...components, CTACard, };}
Now, CTACard
is available for use in any MDX file.
Use the Component in an MDX File
Let's see how to use this CTA component in a blog post:
export const metadata = { ... }# Build Stunning Landing Pages in Minutes with Ready.jsReady.js comes with 16 prebuilt landing page sections designed to help you launch faster. From hero sections to testimonials and CTAs, these components are fully customizable and easy to use. Perfect for developers looking to get their projects off the ground quickly.## Why Use Ready.js for Landing Pages?Building landing pages from scratch can be time-consuming and tedious. With Ready.js, you can skip the initial setup and focus on customizing the content to fit your brand. Whether you're a solo developer or part of a team, Ready.js makes it easy to create stunning landing pages that convert.<CTACardtitle="Ready to Get Started?"description="Download Ready.js and start building beautiful landing pages today."buttonText="Buy Now"buttonLink="/buy"/>## How to Get StartedTo start using Ready.js for your landing pages, simply install the package via npm or yarn. Then, import the components you need and start building. With Ready.js, you can create beautiful landing pages in minutes, not hours.
What's Happening Here:
- The
<CTACard>
component is instantiated like any other React component. - We pass
title
,description
,buttonText
, andbuttonLink
as props to define the CTA's content.
See It in Action
Run your development server and visit the blog post where you added the CTA. You should see a beautifully styled card inviting readers to take action.
This example shows how easily you can enhance your blog posts with interactive elements using MDX. While we created a simple CTA here, you can extend this idea to include charts, calculators, or any other React components that make your content engaging and unique.
Conclusion: Going Further with Your Blog
Congratulations! 🎉 You've built your own blog system using Next.js and MDX. While this is a basic version, it includes all the essential features to get started: dynamic blog post fetching, MDX rendering, custom components, and styling. This foundation is flexible, allowing you to scale and customize your blog as your needs grow.
With this setup, you own your content, can version control it, and have the ability to add interactive elements directly within your posts. It's a powerful, simple solution for developers who want complete control over their blog.
Now, if you want to take your blog a step further, here are a few ideas to make it even better:
- Related Posts Carousel: Add a carousel of related blog posts at the end of each post. Fetch posts with similar tags or categories to keep readers engaged.
- Dynamic Table of Contents: Create a table of contents for long posts that updates dynamically. You'll need to create a custom component for headings that adds an
id
to eachh2
and allows for smooth scrolling. - Code Highlighting: If you're writing a technical blog, add custom code blocks with proper syntax highlighting using libraries like Prism or Shiki.
- Custom Image Component: Gain more control over your images by creating a custom MDX component for them. You can manage positioning, sizing, quality, and more for a polished look.
- SEO Improvements: Optimize your blog for search engines by adding JSON-LD schema, custom metadata, and other SEO-friendly features to help your posts rank higher.
Happy coding!