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, the Studio is great for content management.
next-mdx-remote
Compiles MDX strings from Sanity into React components on the server. No client-side hydration needed.
Prisma + PostgreSQL
Handles 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 (messy) or you use external URLs (fragile). No built-in upload, no optimization pipeline.
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 from Sanity — the client itself and the image URL builder.
Create the client file
I put this in src/lib/sanity.client.ts. The entire app imports from this one 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
import { createClient } from "next-sanity";
import imageUrlBuilder from "@sanity/image-url";
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 || "2024-01-01",
useCdn: false,
});
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
return builder.image(source);
}
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2024-01-01
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 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.
Blog listing query
Used on the /blog page. Fetches all posts ordered by date, with just the fields needed for the card UI.
*[_type == "blog"] | order(publishedAt desc) {
_id,
slug,
title,
shortDescription,
publishedAt,
readingTime,
categories,
coverImage {
asset->{ url }
}
}
Single post query
Used on the [slug] page. Fetches everything — content, embedded images, and videos stored separately in Sanity.
*[_type == "blog" && slug.current == $slug][0]{
_id,
title,
"slug": slug.current,
shortDescription,
content,
coverImage { asset->{ url } },
coverVideo { asset->{ url } },
postImages[] {
alt,
asset->{ url, metadata { dimensions } }
},
postVideos[] {
caption,
asset->{ url, mimeType }
},
publishedAt,
readingTime,
categories
}
Adjacent posts query
Returns the previous and next post relative to the current one — all in a single GROQ query.
{
"previous": *[_type == "blog" && publishedAt < $publishedAt]
| order(publishedAt desc)[0] {
title, "slug": slug.current
},
"next": *[_type == "blog" && publishedAt > $publishedAt]
| order(publishedAt asc)[0] {
title, "slug": slug.current
}
}
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's file-based routing. The file at app/blog/[slug]/page.tsx handles every individual post.
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 }));
}
generateMetadata SEO
Generates OG metadata dynamically per post. Every post gets its own title, description, and OG image — automatically, no extra config per post.
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const post = await client.fetch(singleBlogQuery, { slug });
if (!post) return { title: "Blog Not Found" };
const ogImage = post.coverImage?.asset?.url || "/thumbnailBanner.png";
return {
title: post.title,
description: post.shortDescription,
openGraph: {
title: post.title,
description: post.shortDescription,
type: "article",
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
creator: "@dhirajtsx",
images: [ogImage],
},
};
}
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 — compiling MDX entirely on the server inside a React Server Component.
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 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.
The Diagram edge case
There's a tricky parser bug: Sanity content sometimes contains ASCII diagram text that MDX misreads as JSX tags because of arrow syntax. A pre-processing step wraps those sections in code blocks before compilation. 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
Step 5 — Table of Contents
Since rehypeSlug adds IDs to all headings, I built a sidebar TOC. 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: this runs on the raw MDX string, not compiled HTML. It strips formatting characters and produces slugified IDs that exactly match what rehypeSlug generates. Press Ctrl + F in your browser — heading anchors work in the URL bar the same way.
How it 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;
The listing page is built statically but refreshes in the background every 60 seconds. When I publish a new post in Sanity Studio, it appears on the live site within a minute — without any redeploy.
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.
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 head. This is what Google uses to understand your content beyond just 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,
};
Articles with proper schema can appear in Google rich results — showing publication date, author name, and reading time directly in the search listing.
Other Things Worth Knowing
Local fonts — no Google Fonts requests
Instead of loading fonts from Google (which adds a network round-trip on every page load), I serve them locally using Next.js localFont with display: swap. Text renders immediately in a system font, then swaps to the custom font — no layout shift, no invisible content.
Production console cleanup
One line in next.config.ts removes all console.log statements from the production bundle automatically. No stray debug logs making it to production.
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
WWW redirect SEO
A permanent redirect from www.dhirajbhawsar.in to dhirajbhawsar.in is handled in next.config.ts — so there's 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. I'd recommend spending an afternoon in Sanity Studio's Vision plugin — their built-in GROQ playground — before writing any frontend integration 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 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
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 stack is one of the best options 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 Twitter at @dhirajtsx or visit dhirajbhawsar.in.
Published on June 18, 2026