
Build a Personal Blog with Next.js, MDX, and Server Actions
I first started this website back in 2015, and wrote content for it all the way through 2019, until I decided to pull the plug. That entire time it was run off of a janky wordpress setup with a semi-custom theme duct-taped together. So, when I decided I wanted to rebuild it I knew it had to be simpler and take advantage of the latest technology. I wanted to write Markdown files and have them instantly appear on the site - no CMS, no database, no third-party services. Just files in a repo that turn into pages. After some tinkering I landed on a setup I'm pretty happy with: Next.js, MDX, and a metadata-driven content system powered by server-side functions.
This guide walks through building that same setup from the ground up. I haven't written one of these guides in quite some time though, so hopefully by the end you'll have a personal blog of your own, where posts live as MDX files in your project, metadata is type-safe and simple to manage, and pages render server-side with zero JavaScript overhead. So, let's get started!
What we're building
Before we dig in, I want to just go over some of the parts that make this as seemless as possible. We can think of our blog app as a library – MDX files are the books, the metadata file is the card catalog, and server actions are the librarian who knows where everything is at and can grab it for you on demand.
The pieces break down like this:
- MDX files – your posts, written in Markdown but with the ability to drop React components in wherever you need them
- A metadata file – a typed array holding information about each post (title, slug, publish date, tags, etc.)
- Server actions – async functions that query your metadata to grab posts, filter by tags, sort by date, and whatever else you need
- Dynamic routes – one page template that renders any post based on its slug
Setting up Next.js with MDX support
First things first – get a Next.js project created and wire up MDX. If you don't already have one going, you can just run the following command:
yarn dlx create-next-app@latest my-blog --typescript --app
cd my-blogNext up we need the MDX dependencies. Next.js has great built-in MDX support through the @next/mdx package, but we do need a few other packages:
yarn install @next/mdx @mdx-js/loader @mdx-js/react
yarn install rehype-pretty-code shikiThat second line pulls in rehype-pretty-code and shiki, which we use for syntax highlighting.
Now open (or create) the next.config.mjs file and configure Next.js to handle MDX:
import createMDX from '@next/mdx';
const nextConfig = {
reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], // add mdx to this array
};
const options = {
keepBackground: false,
defaultLang: 'tsx',
theme: 'material-theme-darker', // pick a theme
};
const withMDX = createMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [],
rehypePlugins: [['rehype-pretty-code', options]], // configure your syntax highlighting
},
});
export default withMDX(nextConfig);There are a couple key things here. First, we need to tell Next.js that .mdx files are valid page extensions alongside the usual .tsx and .ts. Second, we're wiring in rehype-pretty-code as a rehype plugin so that fenced code blocks in our MDX get syntax highlighting out of the box. You can set the theme here as well, I picked material-theme-darker, but Shiki ships with tons of themes.
We also need an mdx-components.tsx file in the src/ directory. Next.js requires this for MDX to work with the App Router:
import type { MDXComponents } from 'mdx/types';
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
};
}If you wanted to, this is the file you would use to you'd add custom component overrides – swapping every <a> tag for your own Link component, for example. But, we'll leave it alone for now.
Creating a type-safe metadata system
Here's where we can start to automate the content management. Rather than putting frontmatter at the top of each MDX post you want to write, we're going to store all post metadata in a single TypeScript file. You get type safety and a single source of truth of course, but the big win is how easily we're going to be able to query and filter our posts and dynamical route to them.
Start by defining the shape of the metadata. Create src/markdown/post-metadata.ts:
export interface PostMetaProps {
description: string;
id: number;
image: string;
lastUpdated: Date;
projects: string[];
publishDate: Date;
published: boolean;
slug: string;
tags: string[];
title: string;
}
// Define the metadata array, for example
export const postMetadata: Array<PostMetaProps> = [
{
description: 'How to build a personal blog with MDX and Next.js',
id: 1,
image: '/images/my-first-post.webp',
lastUpdated: new Date('2025-01-15'),
projects: [],
publishDate: new Date('2025-01-15'),
published: true,
slug: 'my-first-post',
title: 'My First Blog Post',
tags: ['next.js', 'react', 'typescript'],
},
];One rule here that's important for the rest: the slug has to match the MDX filename exactly. I tried originaly to do this with just the id field, but then it becomes a bit of a chore to find old posts in your repo. So, if the slug is my-first-post, the file needs to be called my-first-post.mdx. That's how the dynamic route locates the right file to render, and a mismatch will give you a 404 that'll have you tearing your hair out. I ended up with sort of a hybrid approach where I preface the slug with the ID, like this post is "0005-build-a-blog-with-nextjs-and-mdx".
The published flag is also nice, I've built a few safeguards around it so I can commit draft posts to the repo without them showing up on the live site.
Writing server actions for data fetching
Now we need a way to actually query the metadata. Server actions in Next.js are async functions that execute exclusively on the server – ideal for our use case since the metadata never needs to reach the client. We only need it at render time to fetch the MDX file and populate the SEO data.
Create src/app/actions.ts with the 'use server' directive at the top:
'use server';
import { type PostMetaProps, postMetadata } from '@/markdown/post-metadata';
export async function getPublishedPosts(): Promise<Array<PostMetaProps>> {
return postMetadata.filter(post => post.published);
}
export async function getBlogPostsByDate(): Promise<Array<PostMetaProps>> {
return postMetadata.sort((a, b) => +b.publishDate - +a.publishDate);
}
export async function getBlogPostBySlug(
slug: string
): Promise<PostMetaProps | undefined> {
return postMetadata.find(post => post.slug === slug);
}This is pretty straightforward – each function filters or sorts the metadata array and hands back the results. The 'use server' directive at the top is what keeps Next.js from bundling any of this into client JavaScript.
These are just a small set of examples, this pattern allows you to tack on more query functions as your blog grows, like filtering by tags, or performing a rough search:
export async function getPostsByTag(
tag: string
): Promise<Array<PostMetaProps>> {
return postMetadata
.filter(post => post.published && post.tags.includes(tag))
.sort((a, b) => +b.publishDate - +a.publishDate);
}
export async function searchBlogContent(
search: string
): Promise<Array<PostMetaProps>> {
const posts = postMetadata.filter(post => post.published);
const cleanSearch = search.trim().toLowerCase();
return posts.filter(post =>
JSON.stringify(post).toLowerCase().includes(cleanSearch)
);
}I've thought a little bit about how this would scale, and think if it were ever to reach hundreds of posts I might want to transition into a database, but in the meantime I'm not going to worry about it. All of the processing is done server side, can be cached with CDN, and ultimately we're not doing anything really intensive.
Building the dynamic post route
Now to wire up the single page component that will render the blog posts based on their URLs. Create a src/app/blog/post/[slug]/page.tsx file:
import { getBlogPostBySlug } from '@/app/actions';
import type { Metadata } from 'next';
import dynamic from 'next/dynamic';
import { notFound } from 'next/navigation';
interface PostPageProps {
params: Promise<{ slug: string }>;
}
// Next.js system function to generate the SEO for the page
export async function generateMetadata(
{ params }: PostPageProps
): Promise<Metadata> {
const { slug } = await params;
const metadata = await getBlogPostBySlug(slug); // call our server function
return metadata || { title: 'Not Found' };
}
// The actual page template
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const postMetadata = await getBlogPostBySlug(slug);
if (!postMetadata) notFound();
const Post = dynamic(
() => import(`../../../../markdown/posts/${slug}.mdx`) // this is why the metadata slug must be the same as the filename
);
return <Post />;
}There's a quite a bit here if you're new to Next.js, so let's break it down real quick. The [slug] folder name tells Next.js this is a dynamic route – any URL matching /blog/post/whatever hits this component with whatever as the slug param.
generateMetadata is how Next.js lets us set the page <title> and other SEO meta tags on the fly. We look up the post by slug and pass the metadata back, so every post automatically gets the right title and description in its head tags.
In the PostPage component itself, we first confirm the post exists in our metadata. If not, notFound() kicks in and renders a 404 page. After that, dynamic() imports the MDX file using the slug. This is the connection point between our metadata system and the actual content, and exactly why slug-to-filename matching matters so much.
Note: in newer versions of Next.js,
paramscomes through as a Promise that you need toawait. Older versions let you destructure it directly, but theawaitpattern is the direction the framework is going – I'd stick with it.
Creating the blog listing page
Now that you've got a feel for how the server functions work, let's create a page that will render a list of all of our blog posts. You can create a src/app/blog/page.tsx file:
import { getBlogPostsByDate } from '@/app/actions';
import Link from 'next/link';
export default async function BlogPage() {
const posts = await getBlogPostsByDate(); // calls the action that fetches all posts and sorts them by date
return (
<div>
<h1>Blog</h1>
<div>
{posts.map(post => (
<div key={post.id}>
<Link href={`/blog/post/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.description}</p>
</Link>
</div>
))}
</div>
</div>
);
}This is a server component – we didn't add the 'use client' directive, so the page renders server-side, calls our action to get the sorted list, and then returns pure HTML to the browser. This means that this page shouldn't have much JavaScript, and can easily be crawled by bots and enables it to surface in search results.
You can start building on this groundwork from here like adding tag filtering via search params, a search function, pagination, etc. The data layer is done and you're free to do whatever comes to you from here!
Writing your first MDX post
Now that all of the groundwork is laid, we can write our first post. Create your first post at src/markdown/posts/my-first-post.mdx:
# Hello, World!
This is my first blog post written in MDX. I can write regular
Markdown like **bold text** and *italics*, include
[links](https://example.com), and even code blocks.The MDX file basically just the content we want to write – we don't need frontmatter to mess with metadata, but here is where that id property finally gets used. If a user navigates to /blog/post/my-first-post, Next.js matches the slug, grabs the metadata, dynamically imports your MDX, and renders the page.
In our post files we could render other React components into your MDX. One thing that I've done is create a reusable title component where we pass the matching id to a find iterator that matches on the metadata for this page and passes it to the component. Come to think of it, this is probably why I added the ID to the slug... But, anyway, here's how that works:
import { PostTitle } from "../../components/post-title.component";
import { postMetadata } from "../post-metadata";
<PostTitle {...postMetadata.find(i => i.id === 1)} />
And then your content goes below it...This allows us to have consistant layouts, components, styles, etc across all of our posts without having to do it manually each time.
Putting it all together
Quick recap of what we've got:
- Next.js with MDX – the framework is set up to read
.mdxfiles - A metadata file – a single source of truth for post data
- Server actions – lightweight functions that filter and sort posts without exposing anything to the client
- A dynamic route – a single page component that loads any post by its URL slug
- A listing page – rendered on the server and showing all posts ordered by date
Adding a new blog post looks like this:
- Write an MDX file in
src/markdown/posts/ - Add a metadata entry with a matching slug
- You're done!
We don't need to reconfigure any pipelines, don't need to log into and navigate a CRM labrynth, and we have complete control over every aspect of the design, features, and UX.
Where to go from here
You can expand on this to your hearts content; tag-based filtering with URL search params, a search feature using that searchBlogContent pattern, RSS feed generation using the metadata array, and those trendy reading time estimates pulled from the MDX content. I've been using this setup on this blog and so far it's been pretty solid. It's quick, I like that there's few moving parts, and I like how much control it gives me over the site.
Thanks for reading the first of hopefully (atleast) a few posts on the new site. I still manage to pull in a couple hundred views per month to the old articles that no longer exist, so if that's how you got here - sorry their not here! But hopefully you'll stick around.