Building a markdown-powered blog in Next.js (the lazy way)
No CMS, no database, no admin panel. Just .mdx files in a folder, a build step, and a workflow that gets out of your way.
Every time I look at a CMS, I have the same thought: I just want to write things and have them appear on the internet. I do not want a database. I do not want a content modeling layer. I do not want to log in somewhere to push a button. I want to write a file, push to git, and be done.
That's what this blog is. The page you're reading right now is a .mdx file in content/blog — same repo as the rest of my site. I added it, ran git push, and Vercel built a static page out of it. That's the entire publishing workflow.
This post is the explainer for how it works, in case you want the same setup. It's also a small love letter to the file system as a content model.
Why MDX, why a static folder
There are exactly two requirements I had:
- Writing should feel like writing. Markdown, in my editor, with the same shortcuts as everything else. No WYSIWYG.
- The site should still be a real Next.js app. I want to drop in React components when I need them — a chart, an embed, something interactive — without leaving the post.
MDX hits both. It's markdown that also accepts JSX, so 95% of a post is plain prose and the other 5% can be <PullQuote> or <Figure> when the design calls for it. And because the files live in the same repo as the code, my publishing workflow is the same as my code workflow: branch, write, push.
If your blog has more infrastructure than your blog, the blog will lose.— a thing I wrote on a sticky note
The pieces
The whole setup is four packages and two routes. That's it.
Packages:
npm install next-mdx-remote gray-matter remark-gfm rehype-pretty-code shiki reading-timenext-mdx-remote— compiles MDX inside React Server Components.gray-matter— parses YAML frontmatter at the top of each file.remark-gfm— adds GitHub-flavored extras (tables, footnotes, strikethrough).rehype-pretty-code+shiki— syntax highlighting that runs at build time, no client JS.reading-time— turns a body of text into "5 min read".
Routes:
app/blog/page.tsx— the index that lists every post.app/blog/[slug]/page.tsx— one statically generated page per.mdxfile.
That's the whole architecture. There's no API. There's no database. There's a content/blog folder.
The data layer is the file system
Here's the entire lib/blog.ts for parsing posts. It reads the directory, parses frontmatter, and returns sorted post objects.
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import readingTime from 'reading-time';
const POSTS_DIR = path.join(process.cwd(), 'content', 'blog');
export function getAllPosts() {
return fs
.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith('.mdx'))
.map((f) => {
const raw = fs.readFileSync(path.join(POSTS_DIR, f), 'utf-8');
const { data, content } = matter(raw);
return {
slug: f.replace(/\.mdx$/, ''),
...data,
readingTime: `${Math.ceil(readingTime(content).minutes)} min read`,
content,
};
})
.sort((a, b) => (a.date > b.date ? -1 : 1));
}That's it. No ORM. No query builder. The "database" is the file system, and fs.readdirSync is the query language. The whole thing runs at build time, so the runtime cost is zero.
Static generation, the easy way
Each post needs to be its own URL, prerendered at build time. Next.js 15 makes this almost embarrassingly simple:
// app/blog/[slug]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getPostBySlug, getAllPosts } from '@/lib/blog';
export async function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = getPostBySlug(slug);
return <MDXRemote source={post.content} components={mdxComponents} />;
}generateStaticParams tells Next.js which slugs to build. At deploy time, every .mdx file becomes a static HTML page. No SSR, no ISR, no cache busting. Just files.
Custom MDX components
The interesting part of MDX is that you can override how each markdown element renders. You want every h2 in your blog to have a hand-drawn number prefix? Pass a custom h2 component:
function H2({ children }) {
// ...auto-numbering logic...
return <h2><span className="h2-num">{num}</span>{children}</h2>;
}
export const mdxComponents = {
h2: H2,
PullQuote,
Figure,
};The lowercase keys override the standard markdown elements. The capitalized keys add new components you can use directly in .mdx files: <PullQuote cite="...">...</PullQuote>. That's how this post has pull quotes — they're a 6-line React component, called from inside markdown.
Code highlighting that doesn't ship JavaScript
The traditional way to highlight code on the web is to ship Prism or highlight.js, and let the browser tokenize at runtime. This is fine. It's also unnecessary for a static blog.
rehype-pretty-code runs Shiki — the same tokenizer that powers VS Code — at build time. The output is HTML with inline styles per token. The browser receives a finished syntax-highlighted block. Zero client JS, zero flash of unstyled content, identical fidelity to what you see in your editor.
rehypePlugins: [
[rehypePrettyCode, {
theme: { dark: 'github-dark', light: 'github-light' },
keepBackground: false,
}],
]That config above is everything I configured. Shiki picks the language from the fenced block (```typescript), runs the tokenizer, emits styled HTML. It's the kind of thing that should be more annoying than it is.
Theming next to an existing site
The blog in this repo had a constraint: it had to live alongside an existing portfolio with a very loud dark mode (cyber-grid, scanlines, RGB-split flash on theme toggle). A reading view needs none of that.
The trick is scoping. The blog layout sets a single attribute:
// app/blog/layout.tsx
<div data-blog="1">
<BlogNavBar />
{children}
</div>And then the CSS dampens the loud effects only inside that scope:
[data-blog='1'] .scanlines::after { opacity: 0 !important; }
[data-blog='1'] .cyber-grid { opacity: 0.15 !important; }
[data-blog='1'] .theme-flash { display: none !important; }The portfolio stays intense. The blog stays calm. Same ThemeProvider, same toggle, completely different reading experience. No second app, no second theme system.
What the workflow actually feels like
code content/blog/some-new-post.mdx- Write the frontmatter and prose.
- Drop in a
<PullQuote>or<Figure>if you want one. git commit -m "post: some new post"git push- Vercel builds. The post is live at
/blog/some-new-post. It also automatically appears on/blogand in the prev/next links of the neighboring posts.
That's the whole loop. No CMS to log into. No admin UI to maintain. No webhooks to debug. The day I stop writing posts, there's nothing to clean up — just files in a folder.
If you've been putting off starting a blog because the tooling felt heavy, this is the lightest version I've found. The whole setup, end to end, took me an afternoon and survived a complete redesign without me touching a single line of post content.
The code for this exact blog is open on GitHub — feel free to copy whatever's useful.