The stack behind this site
I get asked about this a lot so I figured I'd just write it up once. This site runs on Next.js 16 with the App Router. React 19, TypeScript, Tailwind CSS 4, Prisma with PostgreSQL for the like counters and guest wall, and a bunch of markdown tooling for the blog posts.
No CMS. Blog posts are .mdx files in a folder. I write them in my editor, commit, push, and Vercel deploys it. That's the whole workflow. I tried Contentful once for a different project and spent more time fighting the CMS than writing content. Never again.
The full source is here if you want to skip the blog and just read code: github.com/hannadrehman/blog-nextjs
How the blog posts actually work
Each post lives in its own folder under blog-posts/. Just a folder name (which becomes the URL slug) and an index.mdx inside it:
blog-posts/
how-to-create-a-blog-website-in-nextjs-app-router/
index.mdx # this post
i-built-12-interactive-css-effects-heres-what-i-learned/
index.mdx
top-neovim-plugins-for-developers-in-2022/
index.mdx
...
Every post starts with YAML frontmatter:
--- title: How to Create a Blog Website in Next.js App Router date: 2026-04-06 description: A walkthrough of how I built my blog... tags: - Next.js - React - Tutorials banner: https://images.pexels.com/photos/1181263/... ---
Title, date, description for SEO, tags for the category pages, and a banner image. After that it's just markdown. Write whatever you want.
Reading posts from the filesystem
The loading code is in utilities/blog/blog.ts and it's honestly pretty boring. Read the directory, parse each file with gray-matter, sort by date:
import fs from 'fs/promises'; import path from 'path'; import matter from 'gray-matter'; const postsDirectory = path.join(process.cwd(), 'blog-posts'); export async function getAllBlogsPosts() { const fileNames = await fs.readdir(postsDirectory); const posts = fileNames.map(async (fileName) => { const fullPath = path.join(postsDirectory, fileName, 'index.mdx'); const fileContents = await fs.readFile(fullPath, { encoding: 'utf8' }); const matterResult = matter(fileContents); return { id: fileName, ...matterResult, }; }); const blogPosts = await Promise.all(posts); blogPosts.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime() ); return blogPosts; }
No API. No database. Just fs.readdir and fs.readFile. The results get cached in memory so we're not hitting the disk on every page render.
Reading time is dead simple too. Count words, divide by 200, round up:
export function getReadingTime(content: string): number { const words = content.trim().split(/\s+/).length; return Math.max(1, Math.ceil(words / 200)); }
The listing page
app/blog/page.tsx is a server component. Fetches all posts, renders cards. No useEffect, no loading spinners, no client-side data fetching. The data is just... there. This is the part of App Router that sold me on it.
export default async function Blog() { const posts = await blogUtils.getAllBlogsPosts(); return ( <div> <h1>Blog</h1> <p>{posts.length} articles</p> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> {posts.map((post) => ( <BlogCard key={post.id} title={post.data.title} description={post.data.description} slug={post.id} readingTime={blogUtils.getReadingTime(post.content)} /> ))} </div> </div> ); }
The blog post page
app/blog/[slug]/page.tsx is where it gets more interesting. It does static generation via generateStaticParams() so every post is pre-rendered at build time. SEO metadata comes from generateMetadata() which pulls everything from the frontmatter. And the actual content goes through a markdown renderer.
export default async function BlogItem({ params }: BlogItemProps) { const { slug } = await params; const [blogPost] = await Promise.all([ blogUtils.getBlogPostById(slug), createOrUpdateBlogInDb(slug), ]); const content = blogPost.content || ''; const tocHeadings = extractHeadings(content); const hasToC = tocHeadings.length > 0; return ( <div className="relative pt-8"> {/* Title, reading time, tags... */} <div className={hasToC ? 'lg:grid lg:grid-cols-[220px_1fr] lg:gap-8' : ''}> {hasToC && <TableOfContents headings={tocHeadings} />} <div> <article className="prose max-w-none"> <MarkdownRenderer>{content}</MarkdownRenderer> </article> </div> </div> </div> ); }
See that conditional grid? If the post has ## or ### headings, it gets a sticky Table of Contents sidebar on the left. If not, the content goes full-width. I actually had a bug recently where the grid was always applied regardless, and posts without a ToC got their entire content squeezed into a 220px column. Took me a while to figure out what was going on. The fix was embarrassingly simple.
Markdown rendering
This is the component that turns markdown text into actual React elements. It lives at components/markdown-rendered/markdown-renderer.tsx:
import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { dracula } from 'react-syntax-highlighter/dist/cjs/styles/prism'; export function MarkdownRenderer({ children: markdown }: { children: string }) { return ( <Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={{ h2: createHeadingComponent(2), h3: createHeadingComponent(3), h4: createHeadingComponent(4), code({ inline, className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ''); return !inline && match ? ( <SyntaxHighlighter style={dracula} PreTag="div" language={match[1]} {...props}> {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) : ( <code className={className} {...props}>{children}</code> ); }, }} > {markdown} </Markdown> ); }
remark-gfm gives you tables, strikethrough, task lists. rehype-raw lets you put raw HTML in your markdown which I use for centering images. react-syntax-highlighter handles code blocks with the Dracula theme. The custom heading components generate IDs automatically so the Table of Contents links work.
I went with react-markdown over MDX compilation because it's simpler. I don't actually import React components into my blog posts (even though the files are .mdx). If I ever need that I'll switch, but for now plain markdown with some HTML sprinkled in does everything I need.
Likes with Prisma
The like counter was one of those features that took way longer than it should have. The Prisma schema is tiny:
model Post { id Int @id @default(autoincrement()) title String @unique @db.VarChar(255) createdAt DateTime @default(now()) @db.Timestamp(6) Likes Like[] } model Like { id Int @id @default(autoincrement()) userId String @db.VarChar(255) createdAt DateTime @default(now()) @db.Timestamp(6) post Post @relation(fields: [postId], references: [id]) postId Int }
When you load a blog post, createOrUpdateBlogInDb(slug) makes sure the post exists in the database. The like count is fetched server-side and the button itself is a client component that fires a server action. The userId is just a random string stored in localStorage. It's not secure at all but it's a personal blog, not a bank.
The like counter is wrapped in Suspense so it doesn't block the page from rendering. If the database is slow or down, you still get the blog post. The counter just shows a spinner until it loads.
Styling
Tailwind CSS 4 with a custom dark theme. I'm not using @tailwindcss/typography for the blog content. Instead I wrote my own .prose styles in globals.css:
.prose { @apply text-foreground; } .prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 { @apply text-foreground font-bold; margin-top: 1.5em; margin-bottom: 0.5em; } .prose a { @apply text-primary underline underline-offset-4 hover:text-primary/80 transition-colors; } .prose code { @apply bg-muted text-primary px-1.5 py-0.5 rounded-md text-sm font-mono; }
It's not a lot of CSS. Links get the primary color, code gets a muted background, headings are bold with spacing. Dark mode is handled by next-themes with a toggle in the header. Tailwind's dark: variants do the rest.
I thought about using shadcn/ui's typography component but honestly rolling my own was faster and I know exactly what every line does.
SEO
Every blog post gets OpenGraph tags, a canonical URL, JSON-LD structured data, and an entry in the auto-generated sitemap. All of this comes from the frontmatter via generateMetadata():
export async function generateMetadata({ params }: BlogItemProps) { const { slug } = await params; const blogPost = await blogUtils.getBlogPostById(slug); return { metadataBase: new URL('https://hannadrehman.com'), title: blogPost.data.title, description: blogPost.data.description, keywords: blogPost.data.tags.join(', '), alternates: { canonical: `https://hannadrehman.com/blog/${slug}`, }, openGraph: { title: `${blogPost.data.title} | Hannad Rehman`, description: blogPost.data.description, url: `https://hannadrehman.com/blog/${slug}`, images: [{ url: blogPost.data.banner }], }, }; }
The sitemap at app/sitemap.ts reads all blog posts and generates entries automatically. Same for the category pages. You add a post and everything else updates itself. I never have to think about it.
Table of Contents
The ToC extracts ## and ### headings from the raw markdown with a regex:
export function extractHeadings(markdown: string): TocItem[] { const headingRegex = /^(#{2,3})\s+(.+)$/gm; const headings: TocItem[] = []; let match; while ((match = headingRegex.exec(markdown)) !== null) { const text = match[2].replace(/[`*_~]/g, '').trim(); headings.push({ id: slugifyHeading(text), text, level: match[1].length, }); } return headings; }
On desktop it's a sticky sidebar. On mobile it collapses into a dropdown. An IntersectionObserver tracks which section you're reading and highlights it in the sidebar.
One thing that tripped me up: this function lives in utilities/extract-headings.ts, not in the Table of Contents component. That's because the blog post page needs to call it server-side to decide whether to show the grid layout, but the ToC component is a 'use client' component. Next.js won't let you import a function from a client module into a server component. So I had to pull it out into a shared utility. Small thing but it took me a bit to figure out why I was getting that error.
Static generation
Every blog post is pre-rendered at build time:
export async function generateStaticParams() { const posts = await blogUtils.getAllBlogsPosts(); return posts.map((post) => ({ slug: post.id })); }
Static HTML files. Fast loads. CDN-friendly. The only dynamic part is the like counter which streams in via Suspense.
Deployment
Vercel. Push to main, it builds, it deploys. The database is Vercel Postgres. Three environment variables:
POSTGRES_PRISMA_URL=...
POSTGRES_URL_NON_POOLING=...
NEXT_PUBLIC_POSTHOG_KEY=... # optional
Prisma migrations run during the build. New blog posts get picked up automatically because they're just files in the repo. There's no publish step.
Adding a new post
- Create a folder in
blog-posts/with a kebab-case name - Write an
index.mdxwith frontmatter + markdown - Use
##or###headings if you want a Table of Contents - Commit and push
The listing page, sitemap, category pages, everything updates automatically. It all reads from the same directory.
Things I'd change
I'm using .mdx files but I'm not actually using any MDX features. No component imports, no JSX in posts. I should either rename them to .md or start actually using MDX for interactive examples.
I also don't have an RSS feed which is kind of embarrassing for a blog. It's probably 20 lines of code in a route handler. I'll get to it eventually.
The images situation is not great either. I'm using raw <img> tags because integrating next/image with react-markdown is more hassle than I want to deal with right now. It means no automatic optimization, no lazy loading, no responsive sizing. It works fine but it's not ideal.
And I should probably add some kind of frontmatter validation. Right now if I typo a field name the post just renders with missing data and I don't notice until I look at it. Something like Contentlayer or Velite would catch that at build time.
Go build yours
The whole thing is open source: github.com/hannadrehman/blog-nextjs
A Next.js blog is really not that much code. A folder of markdown files, a utility to read them, a component to render them, and some metadata functions. Everything else is just stuff I added over time because I wanted it. Start simple. Add things when you need them.
Oh, and use ## or ### headings in your posts. Trust me on that one.