How I Built My Portfolio with Next.js and Sanity CMS (And What I Learned)
A real breakdown of how I rebuilt my developer portfolio using Next.js App Router and Sanity CMS — covering GROQ queries, dynamic routing, MDX rendering, ISR, and the honest mistakes I made along the way.

Every developer eventually reaches a point where they look at their portfolio and think — this doesn't feel like me anymore.
That's where I was. I wanted something that wasn't just a static resume with links. I wanted a real product — something I'd actually be proud to send to clients.
So I rebuilt everything from scratch. This is the honest breakdown of how I did it, what I used, and what I learned.
The Stack at a Glance
Next.js 15
App Router, Server Components, ISR, generateStaticParams — everything I needed was already built in.
Sanity CMS
Headless CMS with a generous free tier. GROQ queries are clean and the Studio is genuinely nice to use.
next-mdx-remote
Compiles MDX strings from Sanity into React components entirely on the server. No client-side hydration needed.
Prisma + PostgreSQL
Handles all dynamic data — guestbook entries, analytics, user sessions. Fully typed ORM.
Why I Ditched MDX Files
My previous setup stored blog posts as .mdx files in a /content folder. It worked — until it didn't.
Editing a markdown file means opening a code editor. You can't do that from your phone at 11pm when you want to fix a typo in a published post.
With file-based MDX, images either sit in /public or you use external URLs. No built-in upload, no optimization pipeline, no CDN.
You can't query all posts in a given category from a folder of markdown files without building your own parser. Sanity gives you this for free with GROQ.
A headless CMS stores your content separately and exposes a clean API. You write in a proper editor, content is structured, images are handled — and your frontend just fetches what it needs.
Project Structure
Here's how the blog-related code is organized in the repo:
Step 1 — Setting Up the Sanity Client
Install dependencies
You need two packages — the Sanity client and the image URL builder.
Create the client file
I put this in src/lib/sanity.client.ts. The entire app imports from this single file — no duplicated config anywhere.
Set up environment variables
Create .env.local in your project root with your Sanity project ID, dataset name, and API version.
pnpm add next-sanity @sanity/image-url
Don't skip this
I spent an embarrassing amount of time debugging an empty array response from Sanity. Turned out my .env.local variable name had a typo. Always verify your env config in complete isolation before building anything on top of it.
Step 2 — Writing GROQ Queries
GROQ is Sanity's query language. It's not hard, but it's different — the first hour feels strange if you're used to SQL or GraphQL.
Used on the /blog page. Fetches all posts ordered by date, with just the fields needed for the listing card UI.
*[_type == "blog"] | order(publishedAt desc) {
_id,
slug,
title,
shortDescription,
publishedAt,
readingTime,
categories,
coverImage {
asset->{ url }
}
}
The asset arrow syntax
The asset-> in GROQ is a reference join — it follows the reference and fetches the actual asset document. Without the arrow, you'd just get a reference ID, not the image URL. This trips everyone up the first time.
Step 3 — Dynamic Routing
The blog uses Next.js file-based routing. The file at app/blog/[slug]/page.tsx handles every individual post. Two critical exported functions power the whole thing:
generateStaticParams SSG
Tells Next.js to pre-render all blog pages at build time. Without this, every page load hits Sanity's API. With it, pages are served from the CDN.
export async function generateStaticParams() {
const slugs = await client.fetch(
groq`*[_type == "blog" && defined(slug.current)]{ "slug": slug.current }`
);
return slugs.map((s: { slug: string }) => ({ slug: s.slug }));
}
Step 4 — Rendering MDX Content
Blog post content in Sanity is stored as an MDX string. To render it, I use next-mdx-remote/rsc — which compiles MDX entirely on the server inside a React Server Component. No client-side JS, no hydration overhead.
import { compileMDX } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeHighlight from "rehype-highlight";
export async function parseMDX(source: string, components = {}) {
const { content } = await compileMDX({
source,
components,
options: {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings, rehypeHighlight],
},
},
});
return content;
}
Here's what each rehype plugin does:
remarkGfm
Enables GitHub Flavored Markdown — tables, strikethrough, task lists, autolinks.
rehypeSlug
Adds unique IDs to every heading. Essential for the table of contents sidebar.
rehypeAutolinkHeadings
Wraps headings in anchor links so users can link directly to any section.
rehypeHighlight
Syntax highlights all code blocks using highlight.js. Handles 100+ languages automatically.
Step 5 — Table of Contents
Since rehypeSlug adds IDs to all headings, I built a sidebar TOC. The heading extraction runs server-side on the raw MDX string — before compilation:
export function extractHeadings(mdx: string) {
const headingRegex = /^(#{1,4})\s+(.*)$/gm;
const headings: { level: number; text: string; id: string }[] = [];
let match;
while ((match = headingRegex.exec(mdx)) !== null) {
const level = match[1].length;
const cleanText = match[2].replace(/[*_`~\[\]]/g, "");
const id = slugify(cleanText);
headings.push({ level, text: cleanText, id });
}
return headings;
}
The key detail: extractHeadings runs on the raw MDX string, not the compiled HTML. It strips markdown formatting characters and produces slugified IDs that exactly match what rehypeSlug generates in the rendered HTML. Press Ctrl + F in your browser — you'll see heading IDs appear in the URL bar when you click any TOC link.
How it all fits together
The TocSidebar component receives the headings array and renders smooth-scroll anchor links. Each link's href matches the ID that rehypeSlug assigned to the heading in the compiled HTML — so clicking a TOC item jumps directly to that section.
Step 6 — ISR and Caching
Blog listing page ISR
export const revalidate = 60;
Individual blog pages SSG
Pre-built at deploy time via generateStaticParams. New posts added after a deploy use Next.js's on-demand fallback — the first visitor triggers a build, subsequent visitors get the cached page instantly.
ISR won't save a bad schema
Early on I was leaning on revalidation to paper over a messy Sanity schema. The right move is to design your schema properly first. ISR only controls when the page rebuilds — not how clean your data is.
Step 7 — SEO and JSON-LD
Each blog post gets a full JSON-LD structured data block injected into the page. This is what Google uses to understand your content beyond reading the page text.
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.shortDescription,
author: {
"@type": "Person",
name: "Dhiraj Bhawsar",
url: "https://dhirajbhawsar.in",
},
datePublished: post.publishedAt,
dateModified: post._updatedAt || post.publishedAt,
};


The Diagram Edge Case
There's one tricky bug worth calling out. Sanity content sometimes contains ASCII diagram text that the MDX parser misreads as JSX because of arrow-style syntax. The parseMDX.ts file handles this with a pre-processing regex that wraps Diagram content in code blocks before compilation runs.
Here's what a rendered Diagram looks like:
Request → Next.js Server ↓ generateStaticParams (build time) ↓ client.fetch(singleBlogQuery) ↓ parseMDX(post.content, mdxComponents) ↓ Rendered React Component → Browser
Other Things Worth Knowing
Local fonts — no Google Fonts requests
Instead of loading fonts from Google (which adds a network round-trip on every load), I serve fonts locally using Next.js localFont with display: swap. Text renders immediately in a system font, then swaps — no layout shift, no invisible content.
Production console cleanup Next.js
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
WWW redirect SEO
A permanent redirect from www.dhirajbhawsar.in to dhirajbhawsar.in is handled directly in next.config.ts. No duplicate content penalty from Google for having two versions of the same URL.
What I Actually Learned
I want to be honest here — most "what I learned" sections are just disguised tips lists. Here's what actually caught me off guard.
The syntax for reference joins, projections, and parameterized queries isn't obvious upfront. Spend an afternoon in Sanity Studio's Vision plugin — their built-in GROQ playground — before writing any frontend code.
With the App Router, I don't need client-side data fetching libraries for most things. I just await a Sanity fetch directly inside an async Server Component. It's cleaner, simpler, and faster. The mental model shift took a few days to fully click.
Sanity's CDN caches responses. If you use ISR and useCdn together, your revalidation fetches might return stale CDN data. Disabling the CDN ensures you always get fresh content directly from Sanity's API when Next.js revalidates.
Mine did. Wrong variable name in .env.local — the client initialized fine but all fetches returned empty arrays. Test your env config completely in isolation before building anything on top of it.
Useful Resources
Sanity Documentation
Official docs — schema design, GROQ queries, and Sanity Studio setup
next-mdx-remote on GitHub
The library that compiles MDX strings from Sanity into renderable React components
Next.js App Router Docs
generateStaticParams, ISR, Server Components, generateMetadata — all covered here
If you're more of a visual learner, this is one of the best walkthroughs of the Next.js App Router + Sanity stack on YouTube:
Is This Stack Worth It?
Short answer: Yes
If you want a portfolio that has a real blog you can manage from anywhere, updates without a redeploy, has proper SEO out of the box, and can scale if you decide to write more seriously — this is one of the best combinations available right now.
But not for everyone
If you're building a simple five-page portfolio with no dynamic content — this is overkill. Use static files and keep it simple. This setup shines when you have real content management needs.
Thanks for reading. If you have questions, find me on Instagram at @dhirajtsx or visit dhirajbhawsar.in.
Published on June 18, 2026