Building a Blog Engine with Next.js 16 App Router — What Nobody Tells You
Muhammad Tayyab

I added a CMS-powered blog to my portfolio using Next.js 16, Sanity, and the App Router. The setup looked simple. The deployment had other ideas. Here's what actually happened.
I added a blog to my portfolio today. It took longer than I expected — not because the code was hard, but because of a handful of gotchas that no tutorial mentions. Here's an honest write-up of what actually happened.
Why Sanity?
I wanted a CMS that wouldn't get in my way. No database to manage, no backend to spin up, no vendor lock-in on my content. Sanity gives you a hosted content lake and a fully customizable Studio that you can embed directly in your Next.js app at /studio. It felt like the right call for a portfolio that needs to look sharp but doesn't need to scale to millions of users.
The Setup (the easy part)
The official next-sanity integration is genuinely good. Install a few packages, configure a client, define your schema, and you're querying content in about 20 minutes:
npm install next-sanity @sanity/image-url sanity @portabletext/reactThe Sanity client setup is minimal:
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION,
useCdn: true,
})So far so good. Then I deployed.
Gotcha #1: Env Vars Scope on Vercel
This one got me. I added the three Sanity env vars to Vercel but only set them for the Production environment. The build still failed.
Why? Vercel runs your build in a context that respects environment scope. If your vars are only in Production, a preview build — or even a fresh production deploy triggered by the CLI — might not pick them up correctly depending on how the deploy was initiated.
The fix: go to your Vercel project settings, find each env var, and make sure it's checked for Production, Preview, and Development. All three. Every time.
Gotcha #2: moduleResolution Has to Be 'bundler'
next-sanity uses subpath exports extensively (imports like next-sanity/studio). With TypeScript's default moduleResolution set to node, those imports fail to resolve.
In tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}One line change. But if you miss it, you'll get cryptic module resolution errors and spend 20 minutes staring at the wrong file.
Gotcha #3: The Node Version Trap
The sanity package requires Node >= 20.19. Vercel's default at the time was 20.x — but a lower patch version. The build failed silently in a way that looked like an env var issue, not a Node issue.
I bumped the project to Node 22.x via Vercel's project settings API. But then I made another mistake: I added nodeVersion to vercel.json.
{
"nodeVersion": "22.x" // ❌ this is not a valid vercel.json key
}Vercel rejected the deploy with "should NOT have additional property nodeVersion". The correct approach is to set it in the Vercel dashboard under Project Settings → General → Node.js Version. Not in vercel.json.
Gotcha #4: The /studio Route Needs to Be Dynamic
The Sanity Studio is a fully client-side React app. You can't statically render it. The route needs to be marked dynamic:
// src/app/studio/[[...tool]]/page.tsx
export const dynamic = 'force-dynamic'
import { NextStudio } from 'next-sanity/studio'
import config from '../../../../sanity.config'
export default function StudioPage() {
return <NextStudio config={config} />
}Miss this and you'll get a build error about not being able to statically export a page that uses browser APIs.
What Actually Works Well
Once past the setup friction, the DX is excellent:
• GROQ is a pleasure to write. Querying nested references, filtering by tags, projecting only the fields you need — it's expressive in a way SQL isn't.
• @portabletext/react handles rich text beautifully. Drop in a components prop to customize how each block type renders.
• The embedded Studio at /studio means your client (or you) can write and publish without leaving the site.
• ISR (Incremental Static Regeneration) with a short revalidate window means the blog is fast and stays fresh without manual redeploys.
The Final Architecture
/blog → static page, ISR 60s revalidate
/blog/[slug] → SSG via generateStaticParams
/studio/[[...tool]] → dynamic, Sanity StudioWould I Do It Again?
Yes. Sanity is genuinely good for this use case. The GROQ query language is worth learning, the hosted content lake removes ops burden, and the Studio is flexible enough that you can build exactly the editing experience you want.
The gotchas I hit were all configuration-level — nothing architectural. Once you know them, setup is under an hour. Now you know them.