I spent 3 days choosing between Next.js, Astro, and Hugo for a blog that currently gets 200 visitors a month. That's the kind of overthinking that makes developers fun at parties.
This isn't a framework comparison post. I already wrote one of those. This is the story of building ismatsamadov.com — what's actually running in production, what broke along the way, and what I'd rip out if I started over tomorrow.
I'm going to show you real code from this blog. Not sanitized examples. The actual queries, the actual MDX processing, the actual caching config. If you're building a developer blog in 2026, this is the honest version.
The Stack
Here's everything running on ismatsamadov.com right now:
| Layer | Technology | Version |
|---|
| Framework | Next.js (App Router) | 16.2.2 |
| UI Library | React | 19.2.4 |
| Language | TypeScript | 5.x |
| Database | Neon PostgreSQL (serverless) | — |
| ORM | Drizzle ORM | 0.45.2 |
| MDX | next-mdx-remote | 6.0.0 |
| Auth | NextAuth.js v5 | beta.30 |
| Styling | Tailwind CSS 4 | Solarized Dark |
| Syntax Highlighting | Shiki | 4.0.2 |
| Hosting | Vercel | Free tier |
| Analytics | Google Analytics 4 | — |
| PWA | Custom service worker | — |
No light mode. Solarized Dark or nothing. I'll fight about this.
Let me walk through why I picked each piece.
Why Next.js Over Astro or Hugo
The obvious question first. Astro ships zero JavaScript by default. It has 40-70% better LCP than Next.js for content sites. Hugo builds sites in milliseconds. Either would've been "better" for a blog from a pure performance standpoint.
But ismatsamadov.com isn't just a blog. It has:
- An admin dashboard for writing and managing posts
- Credentials-based authentication for the admin area
- API routes for view counting, subscriber management, and search
- A full-text search endpoint backed by PostgreSQL
- Dynamic sitemap generation with pagination and tags
- RSS feed generation
- JSON-LD structured data per article
That's not a static site. That's a small application that also renders blog posts. And for applications with auth, API routes, server-side data fetching, and a dashboard — Next.js is still the right call. 67% of enterprise teams agree, apparently.
Would Astro work? Technically yes, with islands for the interactive bits. But I'd be fighting the framework for every server-side feature. Next.js gives me React Server Components, server actions, middleware, and API routes out of the box. For this project, shipping a few extra kilobytes of JavaScript is worth not fighting the architecture.
The Database: Neon PostgreSQL + Drizzle ORM
I wanted serverless PostgreSQL because this blog doesn't need a database running 24/7. Neon scales to zero — when nobody's reading, I'm not paying for idle compute.
Neon got acquired by Databricks in May 2025 for around $1B. Since then, pricing dropped 15-25% on compute. The product got better and cheaper. I'll take that deal.
The schema is simple. Eight tables: posts, subscribers, tags, experiences, education, certifications, startups, profiles. The posts table does the heavy lifting:
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt').notNull(),
content: text('content').notNull(),
tags: text('tags').array().notNull().default([]),
status: text('status', { enum: ['draft', 'published'] }).default('draft'),
views: integer('views').notNull().default(0),
readingTime: integer('reading_time_minutes').notNull().default(0),
publishedAt: timestamp('published_at'),
updatedAt: timestamp('updated_at').defaultNow(),
createdAt: timestamp('created_at').defaultNow(),
seoTitle: text('seo_title'),
seoDescription: text('seo_description'),
canonicalUrl: text('canonical_url'),
})
Nothing fancy. Tags are a PostgreSQL text array, not a separate table with a join. That was a deliberate choice — I don't need normalized tag management for a personal blog. A simple arrayContains query handles tag filtering.
Why Drizzle Over Prisma
I picked Drizzle over Prisma for three reasons.
First, no code generation step. Prisma requires prisma generate every time you change the schema. Drizzle doesn't. Your schema is TypeScript. Your types come from the schema directly. No generated client, no extra build step.
Second, smaller bundle and faster cold starts. On Vercel's serverless functions, cold start time matters. Drizzle's runtime is smaller than Prisma's engine. For a blog that might get one request every few minutes, faster cold starts mean faster first-visit load times.
Third, it thinks in SQL. Drizzle queries read like SQL. Here's how I fetch published posts:
export async function getPublishedPosts(limit?: number) {
const query = db
.select()
.from(posts)
.where(eq(posts.status, 'published'))
.orderBy(desc(posts.publishedAt))
if (limit) {
return query.limit(limit)
}
return query
}
And here's the full-text search:
export async function searchPosts(query: string) {
const searchQuery = query.trim().split(/\s+/).join(' & ')
return db
.select()
.from(posts)
.where(
and(
eq(posts.status, 'published'),
sql`(
to_tsvector('english', ${posts.title} || ' ' || ${posts.excerpt} || ' ' || ${posts.content})
@@ to_tsquery('english', ${searchQuery})
)`
)
)
.orderBy(desc(posts.publishedAt))
.limit(20)
}
That's PostgreSQL full-text search exposed through Drizzle's sql template tag. No Elasticsearch. No external search service. Just Postgres doing what Postgres does well.
The one downside of Neon? Slightly higher latency than traditional PostgreSQL. Every query goes over HTTP to a serverless endpoint. For a blog, this is completely irrelevant — we're talking about a few extra milliseconds on reads that are cached anyway. But if I were building something latency-sensitive, I'd notice.
MDX Processing: next-mdx-remote
Every article on this blog is stored as MDX in the database, not as files on disk. The MDX content gets compiled at render time using next-mdx-remote 6.0.0.
Here's the actual processor:
import { compileMDX } from 'next-mdx-remote/rsc'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
export async function processMDX(source: string) {
const { content } = await compileMDX({
source,
options: {
parseFrontmatter: false,
mdxOptions: {
format: 'mdx',
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
},
},
})
return content
}
remarkGfm gives me GitHub-Flavored Markdown — tables, strikethrough, task lists. rehypeSlug adds id attributes to headings. rehypeAutolinkHeadings adds anchor links so you can deep-link to sections. Shiki handles syntax highlighting with proper language-aware tokenization.
The next-mdx-remote output is 400% smaller than mdx-bundler, which is why I picked it. For a blog with dozens of articles, that output size difference adds up.
I considered Contentlayer for type-safe MDX with schema validation. But Contentlayer has been effectively abandoned — no maintainer activity, broken with newer Next.js versions. next-mdx-remote works, it's maintained by HashiCorp, and it does the job. Not perfectly. But reliably.
Rendering: ISR + React Server Components
Here's how individual articles render. This is the actual page component:
export async function generateStaticParams() {
const slugs = await getAllSlugs()
return slugs.map(({ slug }) => ({ slug }))
}
export const revalidate = 3600
export default async function ArticlePage({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
const headings = extractHeadings(post.content)
const content = await processMDX(post.content)
const related = await getRelatedPosts(slug, post.tags)
return (
// ... JSX with ArticleHeader, TableOfContents,
// Suspense boundaries, RelatedPosts, ViewCounter
)
}
generateStaticParams tells Next.js to pre-render every published article at build time. revalidate = 3600 means each page revalidates every hour. So after I publish a new article, it takes at most 60 minutes to appear.
The blog index uses revalidate = 60 — one minute — so new posts show up on the listing page almost immediately.
This is Incremental Static Regeneration (ISR). The best parts of static generation (fast, cached, no server compute per request) with the flexibility of server rendering (data can change, pages update automatically). For a blog, it's the ideal caching strategy.
Everything is a React Server Component. No "use client" on the article page itself. The MDX compiles on the server, headings extract on the server, related posts query runs on the server. The only client components are the reading progress bar and the view counter — small interactive pieces that hydrate independently.
Suspense boundaries wrap the MDX content with skeleton loaders. If the MDX compilation takes a moment, the page shell renders instantly and the content streams in. In practice, it's fast enough that you rarely see the skeleton. But it's there as insurance.
SEO: The Boring Stuff That Actually Matters
I spent more time on SEO than on any visual feature. Here's what's running:
Metadata API — Every page has metadata generated through Next.js's built-in Metadata API. Article pages get dynamic metadata with the post title, description, tags, and canonical URL.
Sitemap — The sitemap is generated dynamically from the database:
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const publishedPosts = await db
.select({
slug: posts.slug,
updatedAt: posts.updatedAt,
publishedAt: posts.publishedAt,
})
.from(posts)
.where(eq(posts.status, 'published'))
.orderBy(desc(posts.publishedAt))
const [tags, totalCount] = await Promise.all([
getAllTags(),
getPublishedPostsCount(),
])
// ... builds URLs for static pages, all posts,
// pagination pages, and tag pages
}
Every article, every pagination page, every tag page — they're all in the sitemap. Automatically. When I publish a new post, the sitemap updates on the next revalidation.
JSON-LD Structured Data — BlogPosting schema on every article. BreadcrumbList for navigation context. Person schema for the author. WebSite schema on the homepage. This is the stuff that gets rich results in Google.
Per-Article OG Images — Each article has a dynamic Open Graph image generated through Next.js's opengraph-image.tsx route. Title, reading time, tags — all rendered into a 1200x630 image on demand.
RSS Feed — Available at /rss.xml. Subscribers can follow the blog through any RSS reader.
robots.ts — Standard robots file allowing all crawlers.
Is all this necessary for a blog with 200 visitors? No. But I built it because I wanted to understand how it works. And honestly, the SEO setup was the most educational part of this entire project.
The PWA: Offline Reading
The blog has a custom service worker that pre-caches all published articles. You can open ismatsamadov.com on a plane, in a subway, or anywhere without internet, and every article you've visited will load from the cache.
The service worker intercepts fetch requests and serves cached responses when the network is unavailable. New articles get cached as soon as they're visited. It's not complicated — maybe 80 lines of code — but it feels like magic when it works.
The catch? As the blog grows, pre-caching everything could get heavy. Right now with a couple dozen articles it's fine. At 500 articles, I'd need a smarter strategy — maybe cache only recent articles, or cache on demand instead of eagerly. This is a future-me problem.
The blog serves strict security headers:
- Content-Security-Policy — Restricts script sources, style sources, and frame ancestors
- X-Frame-Options: DENY — No embedding in iframes
- X-Content-Type-Options: nosniff — Prevents MIME sniffing
- Referrer-Policy — Controls referrer information
- Strict-Transport-Security — Forces HTTPS
Overkill for a blog? Maybe. But headers are free and they prevent entire classes of attacks. There's no reason not to set them.
What Worked
Let me be specific about what I'm happy with.
ISR + SSG is perfect for a blog. Articles are static, load instantly, and update automatically. I never think about caching strategy. It just works. The combination of generateStaticParams for build-time rendering and revalidate: 3600 for periodic updates is exactly what a blog needs.
Drizzle ORM is a joy. The queries are readable, the TypeScript types are accurate, and there's no code generation step. Going from schema change to working query takes minutes. The sql template tag for raw PostgreSQL (like full-text search) is powerful without being dangerous.
Neon's developer experience is excellent. Minimal onboarding, a clean dashboard, and branching is the standout feature. I can create a branch of my production database for testing schema changes. No risk of breaking production data.
Tailwind CSS 4 with a custom theme is fast to iterate on. I defined Solarized Dark as CSS variables and everything just works. No fighting with CSS specificity, no naming collisions, no separate stylesheet management. Dark mode only means I never have to test two color schemes.
The SEO implementation pays off. Google Search Console shows articles getting indexed within days of publishing. The structured data generates rich snippets. The dynamic sitemap means I never manually update anything.
The service worker makes the blog feel native. Offline reading works seamlessly. Returning visitors get instant loads from cache. It's a small feature that makes a big difference in perceived performance.
What Didn't Work
Now the honest part. Here's what frustrated me.
NextAuth.js v5 Beta
NextAuth.js v5 (beta.30) is the worst dependency in the stack. I'm saying this as someone who's grateful the project exists — authentication is genuinely hard, and NextAuth has saved me weeks of work. But the v5 beta has been in beta for over a year.
The API changed between beta releases. Documentation was written for one version and outdated by the next. I spent a full day debugging a session issue that turned out to be a breaking change in a minor beta bump.
My auth is credentials-only — a single admin user with a username and password stored in environment variables. No password hashing (it's just an env var comparison). No password reset flow. No session management UI. It works because I'm the only user, but it's fragile. If this blog had multiple authors, I'd need to rethink everything.
I should've skipped NextAuth entirely and built a simple JWT-based auth with middleware. For a single-user admin panel, NextAuth is massive overkill.
Cache Invalidation
revalidatePath() in Next.js works, but it's coarse. When I revalidate the blog index, it invalidates the entire blog section. I can't say "invalidate just page 3 of the pagination." It's all or nothing.
For a blog with infrequent updates, this doesn't matter. But it's the kind of thing that would drive you crazy in a high-traffic application with frequent content changes. Next.js acknowledged this by introducing more granular caching with the "use cache" directive in v16, but the default revalidation behavior is still blunt.
MDX Limitations
next-mdx-remote works. It compiles MDX on the server, outputs React components, and renders correctly. But there's no type safety on the content. If I write broken MDX — unclosed tags, invalid JSX — I get a runtime error, not a build-time error.
Contentlayer would've solved this with schema validation and type-safe content. But Contentlayer is abandoned. The successor projects are still immature. So I'm stuck with "compile and hope" which is fine until you publish an article with a typo in a JSX tag and the whole page 500s.
I've been burned by this. More than once.
No Draft Preview
When I write an article in the admin dashboard and set it to "published," it goes live. There's no preview. No staging. No "show me what this will look like before readers see it."
This is entirely my fault. I just didn't build it. But every time I publish an article and immediately spot a formatting issue, I wish I had. A proper draft/preview workflow should have been in the first version.
Reading Time Is Static
The reading time for each article is calculated when I insert it into the database. If I edit an article later — adding sections, removing paragraphs — the reading time doesn't update unless I manually recalculate it.
This is a dumb bug. Reading time should be a computed field based on word count, calculated at render time. Instead, it's a static integer in the database. I'll fix it eventually. Probably.
Would I Pick Next.js Again?
Yes.
Next.js satisfaction dropped from 68% to 55% in the State of JS 2025 survey. I understand why. The framework has gotten complicated. RSC, the caching model, App Router vs Pages Router, server actions, middleware — the mental model is heavy.
But for what I'm building — a blog with an admin dashboard, auth, API routes, server-side rendering, and client-side interactivity — Next.js is still the right tool. The ecosystem is enormous. Node.js is used by 48.7% of developers, React by 44.7%. When I hit a problem, someone's solved it before.
Next.js 16.2 brought real performance improvements: RSC deserialization is 350% faster, HTML rendering is 25-60% faster. Next.js 16 introduced better caching semantics and made Turbopack the default. The framework is getting better, not worse.
If this were a content-only site — no dashboard, no auth, no API routes — I'd use Astro without hesitation. Astro's zero-JS-by-default approach is objectively better for content delivery. But ismatsamadov.com needs the full-stack features that Next.js provides.
And honestly? Next.js is fun when you understand it. RSC and ISR together give you a rendering model that's incredibly powerful. The problem is getting to that understanding. The learning curve is steep and the documentation assumes you already know things it should be teaching.
What I'd Change
If I started over tomorrow, here's what I'd do differently:
-
Skip NextAuth v5 beta. Build a simple middleware-based auth with JWT tokens and bcrypt. For a single admin user, I don't need a full auth library. I just need to check a hashed password and set a cookie.
-
Add draft preview from day one. A simple /preview/[slug] route that renders unpublished posts. Protected by the same auth middleware. Maybe 50 lines of code. No excuse for not having this.
-
Compute reading time dynamically. Calculate word count at render time and derive reading time from that. Store it in the database as a cache if needed, but always recalculate on edit.
-
Consider SQLite/Turso instead of Neon. Neon is excellent, but for a personal blog, a SQLite database on Turso would be simpler. One file (conceptually), embedded-style access, and Turso gives you the serverless edge benefits. Neon's branching feature is nice but I've used it exactly twice.
-
Smarter service worker caching. Instead of pre-caching all articles, cache on demand with a size limit. LRU eviction for older articles. The eager caching strategy won't scale.
-
Keep Drizzle. Not changing this one. Drizzle's approach — no generated client, SQL-like syntax, small bundle — is exactly right for serverless deployments. It does what I need without getting in the way.
-
Keep Next.js. The complexity complaints are valid. But for a project that needs both content rendering and application features, nothing else gives you this much in one framework. I'd rather fight Next.js's caching model than stitch together three different tools.
The Real Lesson
Building this blog taught me something I didn't expect: the stack matters way less than you think.
I spent 3 days choosing between frameworks. I spent 2 hours choosing between Drizzle and Prisma. I spent a day agonizing over Neon vs Supabase vs PlanetScale.
The blog's success (or lack thereof) has nothing to do with any of those choices. It has everything to do with whether I actually write articles. The best blog architecture in the world is useless if you don't publish anything.
Next.js, Neon, Drizzle, Tailwind — they're all good tools. They work. They get out of my way (mostly). The articles I publish are what matter.
So if you're reading this while stuck in framework analysis paralysis, here's my advice: pick Next.js if you need app features, pick Astro if you don't, and start writing. You can always rewrite the stack later. You can't get back the time you spent comparing benchmarks.
Sources
- State of JS 2025 — Meta-frameworks
- Next.js 16 Release Blog
- Next.js 16.2 Release Blog
- Neon acquired by Databricks
- Neon PostgreSQL Review
- Drizzle vs Prisma — MakerKit
- Drizzle ORM vs Prisma in 2026
- Astro in 2026: Beating Next.js for Content Sites
- Stack Overflow 2025 Developer Survey
- Next.js Enterprise Market Share
- Hugo vs Astro vs Next.js
- next-mdx-remote — HashiCorp