BUILDING THE PORTFOLIO
Disclaimer: AI was utilized to help write and structure this blog post. However, I have thoroughly read, reviewed, and adjusted the contents to accurately reflect my own technical decisions, architecture, and experiences.
A developer's personal portfolio is rarely just a place to list past projects—it's an active sandbox. It's the one place where we don't have to compromise on tech debt, and where we can experiment with the bleeding edge of the ecosystem just to see what happens.
When it came time to rebuild my portfolio, I didn't want a simple static site generator. I wanted to build a production-grade application that prioritized end-to-end type safety, modular architecture, and top-tier developer experience.
This post dives into how I structured my monorepo, why I adopted TanStack Start over established frameworks like Next.js, how I integrated a headless CMS, and how I deploy everything to Netlify Functions and Cloudflare Workers at the edge.
THE ARCHITECTURE: MONOREPO BY DESIGN
I chose a pnpm workspace monorepo architecture. While it might seem like overkill for a personal site, the benefits of separating concerns into isolated packages are immediate.
/
├── apps/
│ └── public-tanstack-start/ # The main portfolio web app
├── packages/
│ ├── common/ # Shared errors, constants, types
│ ├── design-system/ # Project-local UI component library
│ ├── markdown/ # Markdown utilities and processors
│ ├── seo/ # SEO utilities
│ └── utils/ # Shared utility functions
Shared Logic & Configuration
One of the biggest advantages of this setup is sharing logic. Instead of duplicating configurations, I have global tsconfig.json and ESLint setups that every package and app inherits.
Business logic that doesn't care about the UI (like formatting dates or complex calculations) lives in packages/utils. Shared error structures and Data Transfer Objects (DTOs) live in packages/common. This ensures that apps/public-tanstack-start remains a thin layer focused purely on routing and rendering.
FRONTEND: THE DESIGN SYSTEM DEEP DIVE
Instead of importing an off-the-shelf component library directly into my app, I built @portfolio/design-system. This package serves as a strict UI boundary. My main app doesn't need to know how a button is styled with Tailwind CSS; it just imports <Button> and uses it.
Theming with Tailwind v4 & OKLCH
I went all-in on Tailwind CSS v4 and the OKLCH color space. OKLCH is incredible because it allows for perceptually uniform color adjustments.
I didn't just build standard "Light" and "Dark" modes. The light and dark themes are sequentially inspired by Pokémon Black and White's, my favorite pokemon game, cover art Legendary Pokémon, Reshiram and Zekrom. Beyond that, taking inspiration from Fire Emblem: Three Houses, my favorite fire emblem game of the franchise, I created multiple alternate themes (black-eagles, blue-lions, golden-deer). These are driven entirely by CSS variables and custom Tailwind variants:
@custom-variant black-eagles (&:where([data-theme=black-eagles], [data-theme=black-eagles] *));
@variant black-eagles {
--background: oklch(0.12 0.02 20);
--primary: oklch(0.5 0.2 20);
/* Imperial crimson & gold colors... */
}
Decoupled shadcn/ui
I heavily utilized shadcn/ui to build the components. But because of the monorepo structure, components.json and all the shadcn CLI logic are isolated purely inside the @portfolio/design-system package. When I need a new component, I run npx shadcn@latest add inside the package, and the app consumes it seamlessly.
THE STACK: BETTING ON TANSTACK START
For the core framework, I took a leap and used TanStack Start.
While Next.js or Remix are the safe, industry-standard bets right now, they often rely heavily on "magic"—hidden conventions, implicit file-based routing behaviors, and opaque data-fetching layers that can be hard to debug when things go wrong.
TanStack Start takes the opposite approach: it embraces verbosity in exchange for absolute clarity and end-to-end type safety.
Yes, you have to configure your loaders and link them to your components. But this verboseness is actually a superpower. Because everything is explicit, there is no magic to reverse-engineer. You know exactly how data flows from the server to the client.
With TanStack Router, there is no more guessing route parameters or accidentally breaking a link to a blog post. If I type <Link to="/blog/$slug" params={{ slug: post.slug }} />, TypeScript guarantees that the route exists and the parameters are correct before I ever compile the code. It forces you to write better, more predictable code by leaning heavily into TypeScript's inference capabilities.
React Server Components (RSC) Ready
While TanStack Start gives you exceptional client-side and SSR tools right out of the box, it also embraces the future of React by supporting React Server Components (RSC). Using RSC allows me to keep heavy dependencies (like markdown parsers or syntax highlighters) strictly on the server, sending zero JavaScript to the client for static UI. This keeps the portfolio extremely lightweight and fast, loading only the JS needed for interactivity.
Fetching Data the Modern Way
Instead of writing local Markdown files for my blog, I wanted the flexibility of a headless CMS. I chose Sanity CMS.
But how do you fetch from a CMS securely without leaking API keys, while keeping everything type-safe on the client? Enter TanStack Start's createServerFn:
// lib/blogs/functions/list-blogs.function.ts
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
const listBlogsFunction = createServerFn({ method: 'GET' })
.inputValidator(z.object({ cursor: z.string().optional() }))
.handler(async (ctx) => {
// This runs securely on the server
// Sanity keys are safe here.
const query = `* [_type == 'blog'] | order(updatedAt desc) [0...$limit] { ... }`;
const result = await ctx.context.sanityClient.fetch(query, { ... });
// We validate the payload with Zod before returning to ensure the shape is exact
const responseValidationResult = listBlogsResponseDto.safeParse(result);
if (responseValidationResult.success) {
return responseValidationResult.data;
}
// Application error 500
});
On the frontend, this function is consumed by useSuspenseInfiniteQuery. The result? I get to write what looks like a simple asynchronous function call, and the framework automatically handles creating the API endpoint, executing it on the server, and piping the strictly-typed response back to the client. The function implementation and details are tree-shaken away in the client-side.
Beyond just server-only execution, TanStack Start also offers createIsomorphicFn. This allowed me to write logic that executes on both the client and the server, depending on where it's called. This is incredibly powerful for shared validation logic or data transformations that need to run during SSR on the server, but also needed to run on the browser while hydrating. Using createIsomorphicFn also benefits from Vite's tree-shaking capability, removing unused imports and/or variable declarations, preventing environment poisoning and leaking.
INFRASTRUCTURE: MULTI-CLOUD COMPATIBILITY (CLOUDFLARE & NETLIFY)
While my current main deployment target is Cloudflare Workers for global edge distribution, I deliberately built the application to be platform-agnostic. Thanks to TanStack Start's architecture and Vite's build ecosystem, the app is fully compatible with both Netlify and Cloudflare Workers simultaneously.
Because TanStack Start is fully powered by Vite plugins, adapting the build for different runtimes is surprisingly seamless. Depending on the environment variables at build time, it swaps seamlessly between the @cloudflare/vite-plugin and the @netlify/vite-plugin. Thus, my server functions execute close to the user on either platform, resulting in incredibly low latency when fetching those blog posts from Sanity.
CI/CD Deployment Pipeline
I fully automated deployments using GitHub Actions. Whenever code is pushed to main or dev, an Action kicks off that:
- Installs dependencies using
pnpm. - Injects environment variables securely from GitHub Secrets into a local
.envfile. - Runs a dedicated build script (
pnpm --filter @portfolio/public-tanstack-start... run build). - Deploys directly to Cloudflare using Wrangler via a custom node script.
- Purges the Cloudflare Cache (using
nathanvaughn/actions-cloudflare-purge) so the new build is instantly available globally without users seeing stale data.
CHALLENGES & KEY TAKEAWAYS
- The Bleeding Edge Tax: Adopting TanStack Start meant dealing with a smaller community and fewer StackOverflow answers. When things broke—particularly around SSR hydration—I had to dig directly into the source code and documentation. It made me a better developer, but it definitely took more time. However, with AI Agents and Agent Skills, this is no longer the case.
- Type-Safety is Addictive: Once you experience end-to-end type safety from your database/CMS queries all the way through your router and into your React components, you can never go back to loose typings.
- Monorepos Scale You: Even for solo projects, having a
packages/utilsorpackages/design-systemencourages you to write better, decoupled code.
CONCLUSION
This portfolio is a reflection of how I like to build software: modular, type-safe, and highly performant. The combination of TanStack Start, a custom design system driven by OKLCH themes, and Sanity CMS on Cloudflare's edge infrastructure has been incredibly fun to put together.
Building this setup was challenging, but the resulting developer experience made it completely worthwhile.
In fact, building upon the foundation that my Portfolio project has built, I am developing a calculator for Fire Emblem: Three Houses using the same architecture and stacks.