Skip to content
← All posts

Building this portfolio with Claude Code

·11 min read·

I just shipped this site (the one you’re reading) over a series of evenings with Claude Code. Not “the AI wrote it” — more “we wrote it together,” with me steering, fact-checking, and occasionally telling it to stop. The result is fully content-managed, deployed to Cloudflare Workers, runs an embedded MCP server that any AI agent can introspect, and ships with serious accessibility + SEO work. It’s also the portfolio for a software engineering manager — which makes the meta-point of building it this way part of the pitch.

This post is the honest tour: what the opening prompt looked like, where the AI was wrong and how I caught it, where it was right and what I learned, and why I think the model of “AI as collaborator who needs verification” produced something better than either of us alone.

The opening prompt was the most important code I wrote

The single most important artifact wasn’t the first commit. It was the first prompt — about 200 lines, written in a text editor before I ever opened Claude Code. It declared:

  • The stack: Next.js (App Router), Tailwind v4, Framer Motion, NextAuth v5, Cloudflare Pages with OpenNext, structured TypeScript data layer.
  • The aesthetic: liquid glass, cool blues and purples, frosted cards over an animated gradient background.
  • The pages: landing, base portfolio, tailored portfolio variants under /portfolio/[slug], blog index + post, full admin.
  • The non-negotiables: dark/light with system preference plus a manual toggle, scroll-triggered fades, prefers-reduced-motion, ATS-friendly resume export.
  • The build order I wanted.
  • One line near the top: "This is NOT the Next.js you know — read the docs in node_modules/next/dist/docs/ before writing any code."

That last line mattered more than I expected. It pre-empted half a session of Claude reaching for patterns from Next.js 14 that don’t apply anymore.

A detailed opening prompt isn’t about controlling the AI. It’s about establishing a vocabulary — a set of names, conventions, and constraints that both of us can refer to across dozens of subsequent turns. By the third session, “a tailored portfolio config” meant the same thing to Claude that it meant to me, with no ambiguity, because we’d agreed on the shape on day one. Same for “glass” utilities, the Section wrapper, the ResolvedPortfolio type, the “MCP tool definitions in lib/mcp.ts” pattern. Conventions compound.

Alongside it: a CLAUDE.md and AGENTS.md at the project root, which Claude Code reads at the start of every session. These hold persistent instructions — “Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.” Worth their weight in gold once your conversation history grows past what fits in a single context window.

The stack, briefly

  • Next.js 16 + App Router — modern; docs ship in node_modules/next/dist/docs/.
  • Tailwind v4 — CSS-first config via @theme inline. Glass utilities (.glass, .glass-strong, .nav-pill) defined once, used everywhere. Theme-aware tokens (--color-fg, --accent-text) flip per mode.
  • Cloudflare Workers via OpenNext — Free tier, 3 MiB compressed worker. This constraint shaped many decisions.
  • D1 for blog posts, KV for portfolio + tailored configs + site settings — three KV documents, each editable via /admin without a redeploy.
  • NextAuth v5 (Auth.js) — Credentials provider, bcryptjs password, JWT session, edge-runtime middleware.
  • react-markdown + remark-gfm + lazy client-side highlight.js — server bundle stays tiny; syntax highlighting hydrates in the browser.
  • react-pdf for resume export, JSON Resume schema for ATS — every portfolio page exports a clean PDF AND a structured JSON Resume document.
  • Embedded MCP server — single endpoint, JSON-RPC, 15 tools, 4 prompts.

The Workers 3 MiB ceiling killed three different ideas: server-side syntax highlighting (had to move to client), per-post OG images via @vercel/og (had to drop), and the initial in-memory admin storage (had to refactor for dual-backend fs + KV). Each constraint pushed toward a cleaner architecture.

The back-and-forth: where the AI was wrong

A lot of the value wasn’t in the first attempt. It was in the corrections.

The liquid glass nav, four rounds in

Claude’s first version of the nav added a scroll-aware backdrop blur to the entire header background. I didn’t like it — too much visual weight. Reverted. We settled on per-pill blur with stronger frosting. Then I looked at the production CSS and noticed something:

.nav-pill {
  /* what shipped */
  -webkit-backdrop-filter: blur(10px) saturate(180%);
  /* the standard form was missing entirely! */
}

The unprefixed backdrop-filter had been stripped from the production CSS — meaning Firefox saw no blur at all. Turned out to be Tailwind v4’s Lightning CSS optimizer, which recognized the two declarations as “duplicates of the same logical property” and kept only one. It picked the -webkit- form.

Workaround: hide the values behind a CSS variable so the optimizer can’t statically compare them.

.nav-pill {
  --bdf-nav: blur(10px) saturate(180%);
  -webkit-backdrop-filter: var(--bdf-nav);
  backdrop-filter: var(--bdf-nav);
}

Both forms survive into production. Firefox visitors finally see the glass. Three back-and-forths to find this; a real bug discovered, neither of us could’ve found it without verification.

The auth saga

Two stacked bugs blocked admin login for the better part of a session.

Bug 1: process.env.ADMIN_EMAIL read at module scope returned "" on Cloudflare Workers. On Workers, process.env is populated by OpenNext’s worker entry at request time — at module load it’s empty. The early guard

if (!ADMIN_EMAIL || !ADMIN_PASSWORD_HASH) return null;

short-circuited every credentials submission before bcrypt even ran. Result: every login failed silently regardless of input. Fix: move env reads inside authorize().

Bug 2 (which actually fired first, hiding bug 1): NextAuth v5’s UntrustedHost error blocked the CSRF endpoint entirely because we were behind Cloudflare’s edge proxy. The whole auth stack died with the generic “There was a problem with the server configuration” message. Took 30 seconds to diagnose once I had Cloudflare’s invocation logs enabled and ran wrangler tail — the actual error showed up immediately. Without observability turned on, I’d have been guessing for hours. Fix:

// lib/auth.config.ts
export default {
  // ...
  trustHost: true,
} satisfies NextAuthConfig;

Neither bug would have come from training data alone. The fix to each was obvious once you saw the actual stack trace. The hard part was knowing how to surface the stack trace in the first place.

The Next 16 proxy.ts surprise

Next.js 16 deprecated middleware.ts in favor of proxy.ts. We migrated, dutifully heeding the deprecation. Then OpenNext refused to deploy with Node.js middleware is not currently supported. Consider switching to Edge Middleware.

Turned out proxy.ts defaults to Node runtime, with no runtime override available. OpenNext requires edge middleware. The fix was the opposite of what the deprecation notice suggested: renamed back to middleware.ts, which is deprecated but the only file convention that ships edge runtime today.

Left a comment in the file so the next person doesn’t fall for it:

// Why middleware.ts and not Next 16's renamed proxy.ts? proxy.ts
// defaults to Node runtime and the runtime config can't be overridden.
// OpenNext requires edge middleware. Until that gap closes, this is
// the only path that ships edge.

Accessibility + SEO, the right way

Halfway through I asked for a security + accessibility audit. Two findings that I want to call out specifically, because they show up in the kind of work that gets overlooked on most portfolios.

Light-mode contrast. The original text-accent-400 token on a near-white background hit a WCAG contrast ratio of 2.48 — fails AA, fails AA-large, fails just about everything. The whole site read as “washed out” in light mode. Fix: a theme-aware --accent-text token that flips to a darker shade in light mode (5.9 ratio), plus a sweep across 21 files that replaced 33 + 35 instances of bg-white/X and border-white/X with bg-fg/X / border-fg/X so surface tints flip per theme instead of vanishing on white.

Dark-on-dark code blocks. Every code block on the MCP docs page used bg-black/40 ... text-fg. Works in dark mode. In light mode, text-fg resolves to deep indigo on a near-black surface — nearly unreadable. Fix: a .code-block utility whose bg color, alpha, AND text color all flip per theme.

Plus the standard hygiene: HSTS, X-Frame-Options: DENY, Permissions-Policy, per-page JSON-LD (BlogPosting on post pages, ProfilePage on portfolios), RSS feed, sitemap, skip-to-content link, OG metadata. Constant-time bearer-token comparison so timing side-channels can’t leak the MCP secret byte-by-byte.

This stuff matters. It’s also rare to see done well — which makes it differentiating to do well.

The MCP server, the part I’m most proud of

This site exposes a Model Context Protocol server at /api/mcp — a single JSON-RPC endpoint, no SDK, ~330 lines including comments — that any AI client (Claude Desktop, Cursor, custom agents) can use to read this portfolio.

The tools (all public, read-only):

get_profile           get_summary              get_experience
get_projects          get_skills               get_education
get_certifications    get_testimonials         list_tailored_portfolios
get_tailored_portfolio                         list_blog_posts
get_blog_post         get_resume_json          tailor_resume_to_jd

Plus one auth-gated write tool — publish_post — that requires a bearer token. The Authorization check uses constant-time string comparison and the tool is hidden from tools/list for unauthenticated clients (security through both obscurity AND actual enforcement).

Alongside the tools, four user-driven prompt templates: evaluate_fit, prep_interview, pitch_kevin, and chat_with_kevin. A recruiter in Claude Desktop can pick /evaluate_fit from a slash menu, paste a job description, and get a grounded screen against my background — every claim cited by which tool surfaced the fact.

The tailor_resume_to_jd tool is the most fun. Paste a JD, it scores each of my tailored portfolio variants by tag/skill overlap with the JD text, picks the best match, returns a JSON Resume document tailored to that role. A recruiter using Claude could literally say “tailor Kevin’s resume to this role” and get a usable PDF-or-JSON document in 2 seconds.

The chat widget on the homepage uses this same MCP server internally. It’s not a separate AI integration — it’s the same JSON-RPC tools, called directly via a server-side invokeTool() helper instead of over HTTP. Single source of truth. When the model emits a tool_use block, the server runs the tool and feeds the result back. Every factual claim in the chat cites the tool that surfaced it: (via get_experience)-style. The system prompt forbids fabrication. Hit rate of “this AI just made stuff up about Kevin” is, by construction, zero.

The bigger point: knowing the underlying AI infrastructure enough to make your portfolio agent-readable is itself a meaningful technical signal. Most portfolios are HTML for humans. This one is HTML for humans AND JSON-RPC for agents. Both consumers read from the same KV + D1 data, so the AI and the visitor see identical information — they’re never out of sync.

There’s also a .well-known/mcp.json discovery manifest for forward-looking clients that want to auto-configure.

Testing — both my own tools and the AI’s

Claude is great at writing code. It’s also great at being confidently wrong. I tested this site like I’d test anything production-bound:

  • Static audit: npm audit, grep for dangerouslySetInnerHTML (caught a JSON-LD that needed < escaping), grep for module-scope process.env reads (caught the auth bug from earlier).
  • Live probes via curl: confirmed /admin/* returns 307 to login when unauthenticated, /api/admin/* returns 401 without a session, bad-login responses are identical for known vs unknown emails (no account enumeration), callbackUrl to external domains is rejected (no open redirect), MCP write tools are correctly hidden from unauthenticated tools/list.
  • Rate-limit verification: curl loops verified the per-IP hourly cap, the global daily cap on /api/chat, and the cookie-based session bypass that lets in-flight conversations finish their personal budget when the global cap is hit. “Don’t interrupt someone’s message” was a user-experience constraint I added late, and verifying it actually works required hitting it with both fresh and existing-cookie requests.
  • Visual passes via Claude in Chrome: had Claude drive a real browser through every public page, screenshotting, looking for layout breakage. Caught a small spacing issue in the PDF resume header where the subtitle was drawing into the descenders of my name.
  • Cloudflare observability for production: wrangler tail is the most useful diagnostic tool I’ve used this year. The UntrustedHost error from earlier? 30-second diagnosis. Without it: hours of guessing.

The audit produced a severity-ranked report. Two real findings (no security headers in production responses, the JSON-LD XSS-escape gap), both fixed the same session, both verified live in the deployed HTML afterward.

The CMS, in 30 seconds

Everything you see is content-managed via /admin. Profile, experience, projects, skills, education, certifications, testimonials, tailored portfolio variants, and site copy (eyebrows, titles, hero CTAs, footer text) — all live in three KV documents that I edit through structured forms plus JSON editors. No redeploy required for any of it. Blog posts live in D1, editable through the post editor. Adding a tailored portfolio for a specific job application takes about 60 seconds.

The site is also editable via the MCP server’s publish_post tool — agent-friendly editing for any future automation.

What I’d do differently

A few things I’d revisit, in priority order:

  1. Workers Paid ($5/mo, 10 MiB limit) would let me restore the per-post OG images and bring syntax highlighting back to server-side. The current client-side highlighter shows a brief flash of unstyled code on first paint.
  2. Streaming responses in the chat widget. Currently the server runs the full tool roundtrip before returning; visitors see 3–5 seconds of typing indicator. SSE streaming would smooth that out considerably.
  3. Backups for D1 + KV. A daily cron Worker that exports both to R2 would prevent a one-bad-day disaster.

The takeaway

AI didn’t write my portfolio. AI and I wrote my portfolio together — with software engineering judgment shaping the architecture and a willingness to verify the AI’s output catching the bugs that would otherwise have shipped. The opening prompt mattered most. Knowing what to ask for mattered second-most. Knowing what to verify mattered third.

If you’re a hiring manager reading this: the site you’re on is the most-honest portfolio I could produce. The data is in KV, the tools are exposed via MCP for any agent you point at me, the resume is a toJsonResume() call away, and the chat on the homepage will answer questions about my work grounded in real data — it cannot fabricate.

If you want to chat for real, hit the contact form. It works.

About the author

Kevin Shelley

Engineering Manager bridging people and technology — leading teams that ship production e-commerce while driving adoption of the tools that keep them ahead. See the portfolio · Get in touch.

Keep reading

Related posts