Quite the pAIr
How I built a living résumé with a typed content model, an embedded CMS, and an AI agent as my hands-on partner
Stack
- Next.js
- React
- TypeScript
- Tailwind CSS
- Sanity
- Zod
- Shiki
- Vercel
- GitHub Actions
- Claude Code

Most developer portfolios are a grid of side projects. That's not what I needed. I wanted a site that works as a living résumé: one place for my experience, the work I've done, what I think about engineering, and who I am as a leader. It also needed to be cheap to keep current, because a portfolio that's out of date is worse than none at all.
This post covers how I approached it: the decisions, the architecture, a few problems that were more interesting than expected, and how I used AI-assisted development to build it.
The brief
I started with a site map rather than a tech stack:
- Home: who I am and what I do, at a glance
- About: a professional bio and how I lead
- Résumé: experience, skills, certifications, education
- Work: case studies in architecture, engineering leadership, Sitecore/DXP, and AI-enabled engineering
- Writing: posts on engineering, architecture, AI, leadership, and product strategy
- Contact
Résumé, case studies, writing, and about are the core. Everything else supports those four.
The constraints:
- Each kind of content should live where it's easiest to maintain. Résumé data changes rarely and should be versioned. Articles change often and need a real editor.
- Mistakes should fail the build, not production. A typo in a date or a malformed CMS response shouldn't quietly render something wrong.
- It should look intentional. It needed a real design system with dark mode and accessible defaults, not a theme with my name dropped in.
- It should be practice for the way I want teams to work, including AI in the delivery loop.
The stack
| Concern | Choice | Why |
|---|---|---|
| Framework | Next.js (App Router), TypeScript | Static rendering with incremental revalidation, strong typing end to end |
| Styling | Tailwind CSS v4 | CSS-first theme tokens, no config file to drift |
| Content | Sanity, embedded at /studio | Structured long-form content, image hotspots, an editor that lives with the code |
| Validation | Zod | One schema gives both runtime checks and TypeScript types |
| Code highlighting | Shiki | Server-rendered, theme-aware syntax highlighting with no client JS |
| Icons / motion | Lucide, Framer Motion (sparingly) | Consistent icon set; motion only where it earns its place |
| Hosting | Vercel + GitHub | Preview deploys per push, Analytics and Speed Insights built in |
Architecture
Content: two sources, one set of rules
The most important decision was where each kind of content lives.
Résumé (experience, skills, certs) → TypeScript files in the repo → Zod ┐
├→ Pages (static, ISR)
Case studies + posts → Sanity (GROQ queries) → Zod ┘
About / Contact → Code + a small profile moduleThe résumé is data in the repo. Each role is a typed object, and the whole résumé is parsed with Zod when the module loads. If a date isn't YYYY-MM, or an end date comes before a start date, next build fails with a clear message. Résumé changes go through Git like any other change: reviewed, versioned, and easy to revert.
Long-form content lives in Sanity. Case studies and posts need rich text, images, code blocks, and video, and they need an editor I'll actually enjoy using. GROQ queries project exactly the fields each page needs, and every response is validated with Zod before it reaches a component. If the CMS shape and the code ever drift apart, the build tells me.
Categories are defined once. Work and Writing categories live in a single taxonomy.ts file. The same list drives the Zod enums, the grouped sections on the index pages, and the category dropdown in the Sanity Studio. Add a category in one place and the editor, the validation, and the UI all pick it up.
A few smaller modeling choices paid off:
- Company details are separate from roles. Logos and URLs live in a
companieslookup, so promotions at the same company don't repeat them, and consecutive roles at one employer are grouped visually. - Side work is flagged, not deleted. My part-time creative business is marked
secondaryand appears under "Other experience." It's present, but it doesn't compete with the primary story. - Placeholders are explicit. Anything not yet confirmed is marked
TODO, never invented. A résumé is the last place to be creative with facts.
Routing and layout
The App Router's route groups kept the layout honest:
(site)wraps standard pages in a centered content column.(home)opts out of that column so the hero can run edge to edge, while its inner content still uses the same container and lines up perfectly with everything below./studiorenders the Sanity Studio full-screen, outside the site chrome.
A shared SiteShell (header, main, footer) keeps the groups consistent without duplicating markup.
Rendering strategy
Every public page is statically rendered. Pages that read from Sanity revalidate hourly, and post and case study routes are pre-generated from the CMS at build time. The result is fast, cacheable pages with no request-time work for visitors. On-demand revalidation from a Sanity webhook is on the roadmap, so that publishing is reflected immediately.
The design system
I wanted the design to come from tokens rather than from styling individual pages, so it has three layers:
- Palette: the brand scales (primary navy, secondary blue, neutral grays, and status colors), declared as Tailwind v4 theme tokens.
- Semantic roles:
background,foreground,muted,surface,action,brand-soft,ring, and status pairs likesuccess-soft/success-strong. Components only ever reference roles, never raw palette shades. - Components:
Button,TextLink,Badge,Card,Callout,Heading,Text,Section,Stack, andContainer. Each accepts aclassNamethat merges cleanly with its defaults.
Because components only know about roles, dark mode is one block of CSS: under [data-theme="dark"], the roles point at different palette values. That same selector is reusable in a nice way. The home hero sets data-theme="dark" on itself, so its badge, buttons, and text automatically use their dark-mode colors on the navy background, in both site themes, with no hero-specific overrides.
A development-only /styleguide route shows every token and component in the current theme. It returns a 404 in production.
Accessibility was part of each component from the start: visible focus rings driven by a ring token, aria-current on the active navigation item, a mobile menu that closes on Escape and returns focus, required alt text on every CMS image, and link colors chosen from the palette for readable contrast in both themes.
Problems worth writing about
The interesting part of any build is what didn't go to plan. A few highlights:
Dark mode without a flash, and without a React warning
The site defaults to light mode. If a visitor chooses dark, that choice is saved and has to apply before the first paint, otherwise the page flashes white on every load. The standard fix is a tiny inline script in <head>.
React 19.2 started warning about any <script> it creates on the client, because it never executes those. The obvious alternative, Next's <Script strategy="beforeInteractive">, turned out to be wrong for this: reading the framework source showed that inline scripts with that strategy are queued until Next's runtime loads, which would bring the flash back.
The fix was a small component that renders a normal, executable script on the server, which the browser runs immediately, and an inert type="text/plain" data block on the client, which React doesn't warn about. Instrumenting a headless browser confirmed the theme is applied before the page's JavaScript runs.
Image config that looked right but wasn't
Sanity's image CDN encodes crop and size in the query string. My first Next.js image configuration used the new URL(...) shorthand, which silently means "no query string allowed." Everything built and passed review, then failed the moment real content with a cover image arrived. The fix was the object form of remotePatterns, scoped to this project's image path. The lesson: test with real content as early as you can. No placeholder data would have caught this.
Server vs. client boundaries for the embedded CMS
Importing the Studio config into a Server Component pulled the server builds of Sanity's packages into a tree that needed client builds, and the build failed in confusing ways. Moving the config import into a small "use client" wrapper, with the route itself still exporting metadata from the server, fixed it.
Logos that match the brand
The résumé shows company logos. Their originals were a mix of white-on-transparent, single-color SVG, and a small opaque PNG, so dropping them in as-is would have looked like a collage. Instead, each logo is converted to a single-color shape and rendered through a CSS mask-image, filled with the current text color. Every logo now matches the theme automatically: navy in light mode, white in dark.
Smaller lessons
- Tailwind v4 only emits theme variables that a class actually uses. The style guide read palette values through
var(), so swatches were missing until the palette was declared with@theme static. - Next.js route segment config must be a static literal.
export const revalidate = REVALIDATEfails;export const revalidate = 3600works. - Flex rows stretch images. A listing thumbnail stretched to the card's height until it was pinned with
self-start.
CMS authoring experience
The Studio is part of the product, so I gave it the same care as the site:
- Cover images with hotspots, so automatic crops keep the subject in frame. They're used for listing thumbnails, the article header, and 1200×630 link previews.
- Inline images with required alt text and optional captions, served at the right size with blur placeholders.
- Video embeds for YouTube, Vimeo, and Loom. A single URL parser is shared by the Studio, where it validates links at publish time, and by the site, where it builds the embed URL. YouTube uses the privacy-enhanced domain, and players load lazily.
- Code blocks, with a language list limited to languages the site's syntax highlighter can render.
Delivery
GitHub is the source of truth and Vercel deploys every push. A small CI workflow runs npm ci, lint, and a full production build on every push and pull request. Because the build pre-renders every page, CI also validates the résumé data and the live CMS content against their schemas, so broken content is caught before it ships.
An early GitHub Pages workflow was removed. Pages only serves static exports, which can't support incremental revalidation, image optimization, or the embedded Studio.
Building it with AI
I built this site with Claude Code as a hands-on pair, and it was a deliberate test of the AI-enabled workflow I advocate for teams. The split of responsibilities is what made it work.
I owned direction and judgment. That covered the information architecture, the content model, the design language, what belongs on the résumé, and every choice where taste or accuracy mattered: the script font for my name, how logos are treated, which experience is primary.
The agent owned execution and verification. It scaffolded, implemented, refactored across the codebase, and checked its own work. It ran type checks, lint, and production builds after every change, then drove a headless browser to screenshot pages in both themes and at phone width, and to test behaviors like the theme toggle and the mobile menu.
What I'd pass on to any team adopting this way of working:
- Verification has to be part of the loop. Every meaningful change ended with a build and a visual or behavioral check, not just "the code looks right." That's how the image configuration bug and the React warning's real cause were found.
- Point the agent at the framework's own docs. Next.js ships version-specific documentation inside the package. Reading it, and in two cases the framework source, prevented confident-but-outdated answers.
- Facts come from the human. The agent used only my LinkedIn content for the résumé and About page. Anything it couldn't verify was left as an explicit
TODO, never filled with plausible-sounding text. - Small, reviewable increments. Each request was one coherent change: a hero, a menu, media support. That kept review fast and kept me in control of the direction.
The result wasn't "AI built my website." I made the decisions and the agent did the hands-on work quickly enough that I could spend my time on decisions.
Outcome
What shipped:
- A living résumé backed by validated, version-controlled data
- A case study and writing platform with an embedded CMS, rich media, and syntax-highlighted code
- A token-driven design system with light and dark modes and a development style guide
- Static, revalidating pages on Vercel with analytics and performance monitoring
- CI that validates code and content on every change
If you're planning a similar site, my main advice is to decide where each kind of content lives before you choose a framework. The rest of the architecture follows from that decision.