#Next.js#SanityCMS#WebDevelopment#Tutorial

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.

Blog Image

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:

portfoliooo/src
page.tsxBlog listing page with ISR
page.tsxIndividual blog post — SSG plus dynamic fallback
sanity.client.tsSanity client setup
sanity.queries.tsAll GROQ queries
parseMDX.tsMDX compiler with remark and rehype plugins
extractHeadings.tsExtracts TOC headings from raw MDX
mdx-components.tsxAll MDX component exports
CustomComponents.tsxCallout, Tabs, Cards, Accordion and more
TocSidebar.tsxTable of contents sidebar
BlogLayoutWrapper.tsxTwo-column layout wrapper

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.

Bash
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.

GROQ
*[_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.

TypeScript
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.

TypeScript
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:

TypeScript
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

TypeScript
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.

TypeScript
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,
};
Blog Image
Blog Image

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:

Flow Diagram
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

TypeScript
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