Building a Modern Blog with Next.js and MDX
In this post, I'll walk through the technical decisions behind building this blog. If you're looking to create a similar setup, this should give you a solid starting point.
Why a Rewrite?
Sometimes you look at a project you built a few years ago and think, "yeah, it's time." That was me with my personal website. The old version had served me well, but it was starting to show its age. Dependencies were getting stale, the codebase had accumulated some cruft, and honestly, I just wanted an excuse to play with newer tech.
There's something refreshing about starting from scratch. No legacy decisions to work around, no "I'll fix that later" comments haunting you from 2011. Just a blank canvas and the freedom to do things the way you'd do them today.
The web moves fast. What was considered best practice a couple of years ago might now have better alternatives. React Server Components, Tailwind v4's new CSS-first approach, improved build tooling I wanted to take advantage of all of it. Plus, let's be honest, there's a certain satisfaction in seeing those fresh, zero-vulnerability npm audit results.
So here we are. A ground-up rebuild using the latest and greatest. Was it strictly necessary? Probably not. Was it worth it? Absolutely.
Goals
When I set out to build this site, I had a few key requirements:
- Static export No server required, can be hosted anywhere
- Markdown-based content Easy to write and version control
- Modern stack Latest versions of Next.js and React
- Minimal dependencies Only what's necessary
- Fast and accessible Performance matters
The Stack
Next.js 16 with Static Export
Next.js provides an excellent developer experience and the output: 'export' option makes it trivial to generate a fully static site:
// next.config.ts
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
trailingSlash: true,
};MDX for Content
MDX allows writing content in Markdown while supporting React components. This means I can embed interactive elements when needed:
Regular markdown text with **bold** and _italic_.
<Callout type="info">But also custom React components like this callout!</Callout>The key packages are:
gray-matterParses frontmatter from MDX filesnext-mdx-remoteCompiles and renders MDX at build timerehype-pretty-codeSyntax highlighting with Shiki
Tailwind CSS v4
Tailwind v4 introduces a new CSS-first configuration approach. Instead of a JavaScript config file, you define your theme directly in CSS:
@import 'tailwindcss';
@theme inline {
--color-background: #fafafa;
--color-foreground: #0a0a0a;
/* ... */
}Blog Architecture
Content Organization
Blog posts are organized by year in /content/blog/ for easier management:
content/
└── blog/
├── 2021/
│ └── hello-world.mdx
├── 2022/
│ └── another-post.mdx
└── 2025/
└── building-a-modern-blog.mdx
This keeps the content directory tidy as posts accumulate over time, while URLs remain flat (e.g., /blog/building-a-modern-blog).
Fetching Posts
A recursive utility function finds all posts across year folders at build time:
// Recursively find all .mdx and .md files in directory
function findBlogFiles(dir: string): string[] {
const files: string[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...findBlogFiles(fullPath));
} else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
export function getAllPosts(): BlogPost[] {
const filePaths = findBlogFiles(BLOG_DIR);
return filePaths
.map((filePath) => {
// Slug is just the filename without extension (keeps URLs flat)
const slug = path.basename(filePath).replace(/\.mdx?$/, '');
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent);
return {
slug,
frontmatter: data as BlogFrontmatter,
content,
readingTime: readingTime(content).text,
};
})
.filter((post) => post.frontmatter.published)
.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
}Static Generation
Next.js generates all blog post pages at build time using generateStaticParams:
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}Deployment
The site is deployed to Cloudflare Pages, which offers:
- Free hosting for static sites
- Automatic deployments from Git
- Global CDN
- Custom domain support
The build process is simple:
pnpm build
# Output goes to /out directoryConclusion
This setup provides a great balance of developer experience, performance, and simplicity. The entire site builds in seconds, loads instantly for users, and is easy to maintain.
Feel free to explore the source code for the full implementation.