All projects
Active2026· Designer + builder + writer

alexanderwking.com

The site you're reading right now — built end-to-end, shipped on Cloudflare Pages, polished against Lighthouse budgets, secured behind real headers, and tested at the only spot that actually matters: the auth crypto.

Next.jsFramer MotionCloudflareTypeScript

Why

I've been an engineer for 12+ years and never had a personal site that felt like me. Most of mine looked like every other dev portfolio: white background, sans-serif, list of jobs, link to GitHub. Fine. Bland. Said nothing about how I think or what I actually care about.

This one is different by design. It's a blend of personal and professional — peers and recruiters find substance, family and friends track life updates, and neither audience feels out of place. Dark theme, electric amber accent, a custom cursor on desktop, a Konami-code easter egg that flips it into Windows 98 colors. Sports posts about the 2003 Nets sit alongside an /uses page about my work observability stack. It works.

Stack

Layer Tool
Framework Next.js 16 (App Router) with output: "export"
Runtime React 19 + TypeScript (strict)
Styling Tailwind CSS v4 + CSS variables for the theme
Motion Framer Motion
Content pipeline unified · remark-gfm · rehype-slug · rehype-pretty-code (shiki)
Hosting Cloudflare Pages
Edge logic Cloudflare Pages Functions (for family-gallery auth)
CI GitHub Actions: lint + build + Vitest + Lighthouse CI

Static export everywhere it can be; edge functions only where the problem actually needs them.

The two non-obvious pieces

Auth without a server

Most of the site is static HTML served from the edge. But there's a private route — /gallery/family — that needs gating. Static export can't do auth on its own.

Rather than spin up a backend or move the whole site off static export, I composed two things: a static-export Next.js build + Cloudflare Pages Functions for the auth-only bits. Edge middleware gates the private route by verifying a signed session cookie; a separate edge function issues that cookie after validating a shared password against a server-only secret. No Node server anywhere; the whole site deploys as one bundle.

The composition is what's nice. Static for the 95% case where it's the right tool, edge functions for the 5% that actually needs runtime logic. The boundary lives in the filesystem layout (/functions directory at the repo root vs. everything else in /src), so it's visible at a glance rather than buried in config.

Per-post Open Graph card pipeline

Every blog post gets its own dynamically-generated 1200×630 OG card via Next's opengraph-image.tsx convention in the [slug] segment. The images are pre-rendered at build time using ImageResponse from next/og, sharing visual language with the site-wide card (amber top bar, subtle grid, ghost king silhouette in the corner).

The card surfaces post title, tags, date, and the AWK lockup — so when someone shares a blog URL on LinkedIn / iMessage / Slack, it doesn't just say "Alexander W. King" again. The post itself is the headline.

There's a small Content-Type gotcha to be aware of: Next.js emits image routes without file extensions (literally out/opengraph-image, no .png), and Cloudflare Pages defaults to application/octet-stream on extensionless files. Combined with the nosniff header I set globally, strict consumers like iMessage rich previews skip rendering. Pinning Content-Type: image/png in public/_headers is the fix.

What I built that I didn't expect to need

  • A Python photo-processing script (scripts/process-photo.py) that takes iPhone HEIC files, auto-rotates them, strips EXIF (including GPS — critical for family photos), resizes to 1200px wide, and emits both JPEG and WebP. Reusable in one command per photo.
  • A reading progress bar on blog posts. Standard delight; 2px amber bar that fills as you scroll. Pure CSS transform, raf-throttled, ~80 lines.
  • A gallery lightbox with keyboard nav. Once I had real photos worth seeing full-size, the grid-tile-only view was wrong.
  • Vitest unit tests for the auth crypto. Zero tests was acceptable for the rest of the codebase; for the function deciding "can this person see family photos," not so much. 29 tests, 100% line coverage.

What's intentionally not here

  • A CMS. Markdown files in git, full stop. No Contentful, no Sanity, no headless anything. Faster, cheaper, lower maintenance, and writes are git commits which are perfect change history.
  • A comment system. Mailto link works. If demand emerges I'd add Giscus (GitHub Discussions-backed), not Disqus.
  • A newsletter. Currently a mailto: placeholder on /blog. Will wire up Buttondown when subscriber demand exists; building newsletter infra for 0 subscribers is engineering theater.
  • Analytics tracking pixels. Cloudflare Pages auto-injects privacy-friendly Web Analytics at the edge. No cookies, no GA, no consent banner needed.

Workflow

Every change ships through a PR. Direct pushes to main are blocked at the agent layer. GitHub Actions runs lint, build, Vitest, and Lighthouse CI on every PR; Cloudflare auto-builds a preview URL for each branch and comments it on the PR.

Dependabot opens patch + minor bumps grouped by ecosystem (next, react, content-pipeline, tooling); major bumps each get their own PR for review. Patch/minor bumps can auto-merge once CI passes, gated by repo visibility (currently disabled on private/free-tier; will activate when the repo goes public).

The whole thing is reproducible — README has the local dev setup, ROADMAP has the open questions and forward work.

What I'd do differently

  • Start with the design system in CSS variables from day one. I did this eventually, but a few early components hard-coded amber hex values that I had to retrofit when retro mode (Konami code) needed to swap the palette. Cost: maybe 20 min of cleanup. Worth remembering for next time.
  • Skip the Picsum placeholders. I used Picsum as gallery placeholders while waiting on real photos. Looked fine in dev, signaled "demo" in production. Real photos beat polished placeholders every time — would've shipped the first real ones on day one if I had them processed.