The Ship Log: Notes From Building This Portfolio
Every engineering decision has a backstory. This post is the one for this portfolio — what I built, how I built it, and what surprised me along the way.
The Goals
I had three constraints going in:
- Lighthouse ≥ 98 on every route, no exceptions
- No animation bundles shipped to non-animated routes
- The blog (this thing you're reading) should use the best possible rendering architecture — not just SSG
The third one is what made this interesting. "Best possible rendering architecture" in 2026 means Partial Prerendering, Cache Components, and streaming Suspense boundaries — not a static site generator with a cron job.
The Design System
I called it "Synthetix Noir." It's a dark-first design system built on Tailwind v4's @theme directive. The palette:
- Surface:
#060e20(near-black with a blue tint) - Primary:
#a3a6ff(indigo-violet) - Secondary:
#ceee93(acid green — used sparingly) - Tertiary:
#8ce7ff(cyan)
Tailwind v4 makes light-mode theming surprisingly clean. You declare all your tokens in @theme, then override the ones that need to change in [data-theme="light"]. Every utility class — bg-surface, text-on-surface, etc. — picks up the override automatically. No dark: variants, no class duplication.
The Rendering Model
The homepage (/) is mostly static — it has animations and particle effects that run on the client, but the shell renders server-side. For the blog, I wanted something different.
Cache Components
Next.js 16 ships with Cache Components as an experimental feature. You mark an async server component with 'use cache' and it gets cached at the component level, not the route level. This lets you do things like:
async function PopularPosts() {
"use cache";
cacheTag("blog-list");
// This component is cached independently from the page shell
const posts = await db.query.posts.findMany({ limit: 5 });
return <PostList posts={posts} />;
}
The shell of the page renders from CDN cache. The PopularPosts component renders from a separate cache entry that can be invalidated independently when a new post is published.
Partial Prerendering
PPR (experimental_ppr = true) is the feature that ties it together. With PPR enabled on a page, Next.js pre-renders the static shell to the edge and streams in the dynamic parts via Suspense boundaries — without a round trip to the origin for the shell.
For a blog post, that means:
- The title, body, and header paint from the CDN cache at near-zero latency
- The view count, related posts, and comment count stream in afterwards
- The LCP element (post title) is never blocked by dynamic data
What Surprised Me
Tailwind v4 is a breaking change, in a good way
The mental model shift from Tailwind v3 to v4 is significant. You configure through CSS, not JavaScript. Your design tokens live in @theme. PostCSS is optional. The first few hours felt like relearning, but once it clicked, I found myself writing less configuration and more styling.
'use cache' is still experimental
The Next.js cache boundary docs are good, but the behavior of nested cache components — specifically around cacheTag propagation and de-duplication — took some experimentation to get right. The TL;DR: cacheTag in a child doesn't automatically propagate to the parent cache entry. If you want the parent to also be invalidated, you need to set the tag at both levels.
Bundle isolation is harder than it looks
My original plan was to share Navbar between the portfolio and the blog. Simple, right? Except Navbar is a 'use client' component, and its dependencies get included in the client bundle for every route that imports it.
The portfolio's Navbar is clean — no animation imports, just a scroll listener and the theme toggle. But verifying that took an ANALYZE=true build and a manual inspection of the route manifests. Worth doing early.
The Spec-Driven Process
The blog (and the portfolio before it) was built using a spec-driven process with phase gates. Before writing a line of code, I wrote a spec with numbered requirements (R1, R2, etc.), a test plan, and an acceptance gate. Code only ships if every R# is demonstrably satisfied.
It sounds bureaucratic. In practice, it means I've never shipped a feature that "works in dev" and then quietly broken something else in production — because I have to prove it at every gate.
That process is documented at /docs/blog if you're curious.
What's Next
The blog currently lives on flat-file MDX. Phase 2 replaces that with Payload CMS and Neon Postgres, which means a real admin UI and no more git commits to publish. After that: per-post OG images, RSS, view counts via Upstash, Giscus comments.
If any of this interests you, there's an RSS feed at /blog/rss.xml (Phase 3 — soon).