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

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:

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

FROM CONCEPT TO
CODE </>

LET'S TURN IDEAS INTO
SOLUTION'S

I'm available for full-time roles & freelance projects.

I thrive on crafting dynamic web applications and delivering seamless user experiences.