Project
Personal Site
A portfolio built from scratch with TanStack Start, React 19, and Tailwind CSS v4. The goal was a site that holds up under technical scrutiny while remaining accessible to non-technical visitors.
The problem
Most portfolio sites land in one of two failure modes: a generic template with swapped-in text, or an over-engineered tech showcase that buries the actual content. The goal here was to avoid both — a site with a clean enough design that a non-technical recruiter could navigate it, and clean enough code that a senior engineer could read the source and get a reliable read on how I work.
Building it from scratch also served as a forcing function. It covered enough surface area — routing, SSR, component design, deploy pipelines — that it could stand in as a real project on a portfolio that's still light on public work.
Key decisions
TanStack Start over Next.js. Next.js would have been the default choice — more documentation, more community examples, less risk. TanStack Start's routing model is more explicit and its handling of client/server boundaries cleaner. It was also a genuine learning target rather than a technology I'd already worked with extensively.
Tailwind v4 with OKLCH color tokens. Tailwind v4 moves configuration into CSS, so custom design tokens live in the same stylesheet as everything else rather than a separate JS config file. OKLCH is perceptually uniform — adjusting lightness by a fixed increment produces a visible, predictable step, which makes building a consistent palette easier than working in hex. The trade-off is that Tailwind v4 was in active development throughout the build and had breaking changes to navigate.
A post-build pre-render script instead of Nitro's static preset. Nitro's static preset conflicts with TanStack Start's SSR renderer in production and causes a fatal build error. The workaround is a Node script that runs after the Vite build, boots the compiled Nitro server, fetches every route, and writes the HTML to disk. It's an unconventional approach but produces identical output to a static export without touching the preset.
Content in TypeScript files, no CMS. Projects, posts, and experience data live in typed data files. It means content updates require a code change, which isn't ideal if someone else were ever editing the site. For a single-author portfolio, the benefit of full type safety and zero external dependencies outweighs the inconvenience for now.
What I'd do differently
The resume was added with spaces in the filename. That forced URL encoding everywhere the path is referenced and was entirely avoidable. Enforcing URL-safe filenames for public assets from the start is a small discipline with no downside.
The design token naming — forest-ink, canopy — describes the palette metaphor rather than semantic intent. It works internally but doesn't align well with Shadcn's token conventions, which sit alongside it. A thin semantic layer mapping intent-based names to the palette values would have made the two systems easier to reconcile as the component library grew.
Shipping a .docx as the resume download was the path of least resistance. A PDF renders inline in any browser, is easier to forward, and is a more professional format for a document likely to be shared. Worth the extra step to export a PDF from the start.