2025-07-21 by Remi Kristelijn
In this third post of our series, I'll show you how to add MDX (Markdown + JSX) functionality to your Next.js blog. MDX allows you to use React components within your markdown content, making your blog posts more dynamic and interactive.
MDX is a format that lets you write JSX in your markdown documents. This means you can:
Add the necessary MDX packages to your project:
1npm install @next/mdx @mdx-js/loader @mdx-js/react gray-matter
Package explanations:
@next/mdx: Next.js MDX integration@mdx-js/loader: Webpack loader for MDX files@mdx-js/react: React components for MDXgray-matter: Parse frontmatter from markdown filesUpdate your next.config.ts to include MDX support:
1import type { NextConfig } from "next"; 2import createMDX from '@next/mdx'; 3 4const withMDX = createMDX({ 5 options: { 6 remarkPlugins: [], 7 rehypePlugins: [], 8 }, 9}); 10 11const nextConfig: NextConfig = { 12 output: "export", 13 trailingSlash: true, 14 images: { 15 unoptimized: true 16 }, 17 pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], 18}; 19 20export default withMDX(nextConfig);
Organize your blog content in a dedicated directory:
1mkdir -p src/content/posts
This structure keeps your content separate from your application code.
Create TypeScript interfaces for your blog posts in src/types/index.ts:
1// See src/types/index.ts for the actual Post interface 2export interface Post { 3 id: string; 4 title: string; 5 excerpt: string; 6 date: string; 7 author: string; // Added in later updates 8 slug: string; 9 content: string; 10} 11 12export interface PostPageProps { 13 params: Promise<{ slug: string }>; 14}
Build src/lib/posts.ts to handle content operations:
1import fs from 'fs'; 2import path from 'path'; 3import matter from 'gray-matter'; 4import type { Post } from '@/types'; 5 6const POSTS_DIRECTORY = path.join(process.cwd(), 'src/content/posts'); 7 8export function getAllPosts(): Post[] { 9 try { 10 const fileNames = fs.readdirSync(POSTS_DIRECTORY); 11 const mdxFiles = fileNames.filter(fileName => fileName.endsWith('.mdx')); 12 13 const posts = mdxFiles.map(fileName => { 14 const slug = fileName.replace(/\.mdx$/, ''); 15 return getPostBySlug(slug); 16 }).filter((post): post is Post => post !== undefined); 17 18 return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); 19 } catch (error) { 20 console.error('Error reading posts directory:', error); 21 return []; 22 } 23} 24 25export function getPostBySlug(slug: string): Post | undefined { 26 try { 27 const fullPath = path.join(POSTS_DIRECTORY, `${slug}.mdx`); 28 29 if (!fs.existsSync(fullPath)) { 30 return undefined; 31 } 32 33 const fileContents = fs.readFileSync(fullPath, 'utf8'); 34 const { data, content } = matter(fileContents); 35 36 if (!data.title || !data.date || !data.excerpt) { 37 console.warn(`Missing required frontmatter fields in ${slug}.mdx`); 38 return undefined; 39 } 40 41 return { 42 id: slug, 43 slug, 44 title: data.title, 45 date: data.date, 46 excerpt: data.excerpt, 47 content 48 }; 49 } catch (error) { 50 console.error(`Error reading post ${slug}:`, error); 51 return undefined; 52 } 53} 54 55export function getAllPostSlugs(): string[] { 56 try { 57 const fileNames = fs.readdirSync(POSTS_DIRECTORY); 58 return fileNames 59 .filter(fileName => fileName.endsWith('.mdx')) 60 .map(fileName => fileName.replace(/\.mdx$/, '')); 61 } catch (error) { 62 console.error('Error reading posts directory:', error); 63 return []; 64 } 65}
Create src/content/posts/hello-world.mdx:
1--- 2title: "Hello World" 3date: "2024-01-15" 4excerpt: "Welcome to my first blog post!" 5--- 6 7# Hello World 8 9Welcome to my first blog post! This is written in MDX, which means I can use **markdown** syntax and even React components. 10 11## Features 12 13- ✅ Markdown support 14- ✅ React components 15- ✅ Frontmatter metadata 16- ✅ TypeScript integration 17 18## Code Example 19 20```javascript 21function greet(name) { 22 return `Hello, ${name}!`; 23}
In future posts, we'll explore how to add custom React components to make our content even more interactive.
## Step 7: Create Blog Listing Page
Update `src/app/posts/page.tsx` to display all posts:
```typescript
import { Container, Typography, Box } from '@mui/material';
import Navigation from '@/components/Navigation';
import PostCard from '@/components/PostCard';
import { getAllPosts } from '@/lib/posts';
export default function PostsPage() {
const posts = getAllPosts();
return (
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<Navigation title="Blog Posts" showHome={true} showBack={false} />
<Container maxWidth="md" sx={{ flex: 1, py: 4 }}>
<Typography variant="h3" component="h1" gutterBottom sx={{ mb: 4 }}>
Blog Posts
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</Box>
</Container>
</Box>
);
}
Create src/app/posts/[slug]/page.tsx for dynamic post routes:
1import { notFound } from 'next/navigation'; 2import { Container, Box } from '@mui/material'; 3import Navigation from '@/components/Navigation'; 4import PostContent from '@/components/PostContent'; 5import { getPostBySlug } from '@/lib/posts'; 6import type { PostPageProps } from '@/types'; 7 8export default async function PostPage({ params }: PostPageProps) { 9 const { slug } = await params; 10 const post = getPostBySlug(slug); 11 12 if (!post) { 13 notFound(); 14 } 15 16 return ( 17 <Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> 18 <Navigation title={post.title} showHome={true} showBack={true} /> 19 20 <Container maxWidth="md" sx={{ flex: 1, py: 4 }}> 21 <PostContent post={post} /> 22 </Container> 23 </Box> 24 ); 25}
Create src/components/PostCard.tsx:
1import Link from 'next/link'; 2import { Card, CardContent, Typography, Box } from '@mui/material'; 3import type { PostCardProps } from '@/types'; 4 5export default function PostCard({ post }: PostCardProps) { 6 return ( 7 <Card component={Link} href={`/posts/${post.slug}`} sx={{ textDecoration: 'none' }}> 8 <CardContent> 9 <Typography variant="h5" component="h2" gutterBottom> 10 {post.title} 11 </Typography> 12 <Typography variant="body2" color="text.secondary" gutterBottom> 13 {new Date(post.date).toLocaleDateString()} 14 </Typography> 15 <Typography variant="body1"> 16 {post.excerpt} 17 </Typography> 18 </CardContent> 19 </Card> 20 ); 21}
Create src/components/PostContent.tsx:
1import { Typography, Box } from '@mui/material'; 2import ReactMarkdown from 'react-markdown'; 3import type { PostContentProps } from '@/types'; 4 5export default function PostContent({ post }: PostContentProps) { 6 return ( 7 <Box> 8 <Typography variant="h3" component="h1" gutterBottom> 9 {post.title} 10 </Typography> 11 <Typography variant="body2" color="text.secondary" gutterBottom> 12 {new Date(post.date).toLocaleDateString()} 13 </Typography> 14 <Box sx={{ mt: 4 }}> 15 <ReactMarkdown>{post.content}</ReactMarkdown> 16 </Box> 17 </Box> 18 ); 19}
Start your development server:
1npm run dev
Visit http://localhost:3000/posts to see your blog listing
Click on a post to view the individual post page
All your MDX files should follow this frontmatter structure:
1--- 2title: "Your Post Title" 3date: "YYYY-MM-DD" 4excerpt: "Brief description of your post" 5---
In the next post, we'll integrate Material-UI to replace the basic HTML with beautiful, consistent UI components. This will give your blog a professional, modern appearance.
Your Next.js blog now supports MDX! You can create rich, interactive content using markdown and React components. In the next post, we'll enhance the visual design with Material-UI.