Coding ProjectFeatured

Personal Portfolio — Next.js 15 + Sanity CMS

I built this portfolio as a monorepo with Next.js 15 App Router and Sanity as a headless CMS — and then made the site itself the first project entry. Here's how it all fits together.

in progress
maxcsh-portfolio

Why I built it this way

I wanted a portfolio that I could actually maintain long-term without touching code every time I add a project, write a post, or upload new photos. The answer was a headless CMS — specifically Sanity — sitting behind a Next.js 15 frontend. Everything you're reading right now came out of Sanity's dataset, fetched via GROQ, and rendered by React Server Components. No static JSON files, no hardcoded arrays.

And since the site itself is a decent piece of work, it felt right to make it the first entry in the portfolio. This post is that entry.

Monorepo structure

The repo is an npm workspace monorepo with two packages: nextjs-app/ (the public site, port 3000) and studio/ (Sanity Studio, port 3333). They share the same Sanity project ID and dataset, so I can author content in Studio and watch it appear in the browser in real time — no page reload, no rebuild, thanks to the Sanity Live Content API wired up via a <SanityLive> component in the root layout.

One thing I really like about this setup: I have a single sanityFetch() helper wrapping next-sanity's fetch. Every page uses it. It handles draft mode transparently — when I'm logged into the Sanity preview session, it automatically fetches draft content so I can review unpublished changes in full page context before hitting publish.

The type generation trick I love

I don't write TypeScript types for my Sanity documents by hand. After any schema change in Studio, I run npm run typegen inside nextjs-app/ and it regenerates sanity.types.ts automatically from the live schema. Even better — GROQ query result types are inferred directly from the query string itself, so if my query projection doesn't return a field my component expects, TypeScript tells me immediately at compile time. It's a tight feedback loop that catches a whole class of bugs before they ever hit the browser.

The portfolio section

The /portfolio page is a filterable, paginated masonry grid. The interesting design decision here: all filter state (category, technology stack, featured flag, free-text search) lives exclusively in URL params. There's no useState, no Zustand, no context store for filters. When you pick a filter, the hook does router.push() with updated params, the URL changes, and the server component re-runs with fresh data from Sanity. Every filter combination is bookmarkable and shareable out of the box, for free.

The photography section

Photography felt like a different kind of content from projects, so I created a separate photoPost document type in Sanity. Each post supports up to 20 images, a caption, a location, and tags. The clever part: those tags auto-generate album pages at /photography/album/[tag] using Next.js generateStaticParams — I never have to create an "album" document in the CMS. Tag a photo "taiwan" and there's instantly a Taiwan album page.

Design: Neo Brutalism

I went with a Neo Brutalism aesthetic — bold black borders, heavy typography, a mostly black-and-white palette with deliberate accent colors. I wanted something that felt intentional and slightly raw rather than the polished-but-generic look you get from dropping in a component library. Everything is Tailwind utility classes; no separate design-token layer, no CSS-in-JS. Keeps the setup lean and the styles co-located with the components.

Deployment gotcha

One thing that tripped me up: sanity typegen needs access to the Studio workspace files to generate types, but Vercel's build environment only gets the nextjs-app/ directory. My fix was to override the Vercel build command to plain next build (skipping typegen) and commit sanity.types.ts to the repo. The types are always up to date locally since I run typegen before pushing; Vercel just uses whatever is in the repo.

Sanity Studio is deployed separately via npx sanity deploy to Sanity's own hosting. That way I can author content from any browser, not just my local machine.