Back to Blog

I Finally Moved My Site to TanStack Start (and Stepped on Every Rake)

6/29/2026

I've been a TanStack fan for a long time. Tanner's stuff just clicks for me. I used React Query before I really understood why I needed it, and then one day it was load-bearing in everything I built. So when Router showed up with its absurd type safety and that "the URL is state too" philosophy, I was in. And when Start started taking shape on top of it, full-stack React without the magic, I was genuinely excited in the way you get excited about a tool before you've even had a reason to use it.

This site already used TanStack Router. So in my head, I was basically already living the dream. Then I actually looked.


Wait, I'm Not Even on Start

Turns out following a project for years and actually using the headline feature are two different things. My site was Router in plain SPA mode. A client-only createRoot().render(), a hand-written route tree, and a static bundle on Vercel. No SSR, no server functions, no streaming. The thing I'd been excited about for years, Start, was a dependency I hadn't installed.

So I decided to actually do it. And because I have a habit of arguing with myself before I write any code, I started with the most important question: what does Start actually buy me here? Because "be on the cool framework" is a feeling, not a reason.


What Start Actually Buys Me (and What It Doesn't)

The dream pitch for Start is SSR, server functions, streaming, server-side auth. And I just keep reading posts on twitter about the speed and performance of sites using Start. Sounds amazing. But I made myself walk through each one against my actual setup, and most of them quietly fell over.

Server functions replacing my backend? No. My data lives behind a gateway on a VPS, talking to a private MongoDB that has no public port on purpose. Start's server functions, deployed on Vercel, are just serverless functions, and they hit the exact wall I'd already built the gateway to avoid. On top of that, my live Pokémon battle arena runs over a WebSocket that the gateway proxies, and Start has no story for that. So "replace the gateway" was dead on arrival.

Server-side auth? Also pointless for me. My public pages have no user, and everything behind a login stays a client-only island anyway. There was no SSR-with-auth surface for it to improve.

What survived was one real, honest win: static prerendering for SEO and first paint. My blog, docs, and project pages are all just bundled content, perfect candidates to render to HTML at build time. That's it. That's the prize. Smaller than the marketing, but real, and the right reason to do the work. Funny how that goes.


Doing It One Slice at a Time

I broke it into thin slices: stand up the Start pipeline and prove it on one route, then convert the blog, then docs, then projects, then the leftovers, then the auth/live islands, then a final cutover. Each one a PR I could actually reason about. The keystone slice was the scary one, because my whole stack is bleeding-edge (Vite 8 on rolldown, React 19, TypeScript 6, Tailwind 4), and the only question that mattered was: does Start's SSG even run on this?

It does. And then it immediately handed me a string of small humiliations.


Every Rake in the Yard

The first one got me good. My build "succeeded" and prerendered two pages, except the HTML was 358 bytes of nothing. Start owns the whole document now through a root route, but my old index.html was still sitting there, shadowing it, so the prerender happily emitted the empty SPA shell instead of real content:

<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

Deleting index.html and main.tsx fixed it, and suddenly the page was 11 KB of actual hero text and nav links. The issue plan had said to leave those files for the cutover. The build disagreed.

Then Tailwind. The PostCSS plugin couldn't resolve @import "tailwindcss" during the SSR build, so I swapped to the @tailwindcss/vite plugin, which is the recommended path anyway. Then Vercel failed to find it, because Vercel skips devDependencies at build time and the plugin that loads vite.config obviously needs to exist. Moved it to dependencies. Onward.

The most interesting one was serialization. Start serializes your loader data to hydrate the client, and my content modules each carried a load thunk to lazily import the body. Functions don't serialize. So every route loader was crashing the build with a Seroval error until I learned the rule: loaders return plain metadata, and the body gets resolved separately by slug.

// loader data must be serializable — strip the load thunk
loader: async ({ params }) => {
  const post = await blogCollection.metaBySlug(params.slug);
  if (!post) throw notFound();
  return { post };
},
// the page resolves the actual body component by slug, lazily

The nice surprise: because Start waits for Suspense to settle during prerender, that lazy body still renders into the static HTML. Full article in the page source, no "Loading…" flash. That felt like magic the first time I viewed source.

A few more, quickly, because they all cost me a build or two:

  • A nested dynamic route like /docs/$category/$slug needs an actual layout file with an <Outlet/>. Without it, the child route quietly 404s. The generator won't invent the parent for you.
  • ssr: false does not mean "skip prerendering." It controls request-time rendering, but the SSG crawler still happily marches into your client-only islands. You have to exclude them yourself.
  • Stacked PRs will betray you. I merged a base branch to main before its stacked child, and the child's commits got stranded. Had to recover them from the PR ref. Lesson learned: target main, every time.

The Part Where It Got Fun

Once everything was on Start, I went looking for cleanup, and this is where the migration paid me back. I'd been copying the same content-loading lifecycle, glob, cache, lookup, that serialization strip, across four different content types. So I pulled it into one deep little module and made the four types thin configs over it. The invariant that keeps SSG correct now lives in exactly one place instead of nine.

The best moment was almost an accident. I'd assumed one of my build-config guards was leftover scaffolding and tried to delete it, and the build started prerendering junk pages at encoded URLs. Turned out a bunch of my doc cross-links were using the category's display name instead of its slug, so they 404'd. For real readers, not just the crawler. A refactor that found an actual bug. I'll take it.


Was It Worth It?

Honestly? For a personal site, the SEO win alone is a thin justification, and I want to be straight about that. But I didn't only do it for the SEO. I did it because I'd been watching this thing get built for years and I wanted to actually live in it, and because the act of migrating forced me to understand my own architecture better than I had before. I found a bug. I deleted a pile of duplication. I finally know what ssr: false does and doesn't do.

Sometimes you adopt the tool for the headline feature and stay for everything it teaches you on the way in. Start is genuinely good, the type safety is unreal, and stepping on every rake in the yard was, weirdly, the most fun I've had on this site in a while.

Thanks for reading.