When TanStack Start was first announced, I was excited to try it out because its philosophy and design immediately resonated with my approach to frameworks. I started Sample Vault on TanStack Start 1.91.3 in December 2024, while Start was still in alpha, as a small experiment to learn the framework. Campaign Finance followed in February 2025. Both now earn revenue.
Sample Vault is a desktop-first application for music producers, built with Tauri, while Campaign Finance is a web application for political campaign finance management. They exercise different parts of the framework: bundled desktop clients talking to a remote backend in one, server rendering and browser navigation in the other.
After nearly two years, I'd choose TanStack Start again for both, and for most greenfield projects. The barrier to entry is low and it integrates tightly with industry-established solutions like TanStack Query and Router. On top of that, full-stack type safety also gives our AI-assisted work a much-needed compiler feedback loop. The main cost has been investigating execution and transport behavior that wasn't obvious from the application code, including failures the types couldn't protect us from.
Why I moved away from Next.js
I started to adopt Next.js around version 12, moving on from a standard Webpack client and Node.js server stack. The largest application I had the opportunity to lead had around 10 engineers on the team. As the project grew, build times and, in turn, the developer experience degraded significantly. Package integrations, including internationalization, also had their own quirks. The cost showed up in ordinary development every day; a one-line change in one feature meant waiting on a build of the whole app.
We eventually split the repository into vertical-slice packages in a monorepo so that each package could build independently and use its own cache, rather than making every engineer wait for the whole application whenever they needed to work on one part of it. That was maintenance work we had to fund before we could improve the product. If you work with that structure, Preen, our architecture linter, is a separate tool for enforcing its boundaries and conventions.
Around the App Router transition, we upgraded in the hope that the newer tooling would improve things. The migration broke integrations around i18n, authentication, and middleware, and required substantial refactoring without giving us the improvement we expected.
The harder cost was keeping the team consistent: you could explain a convention once, but every new feature and package integration was another place where someone had to know which rules applied and whether the implementation still followed the framework's intended usage. Later caching behavior and directives added to that burden. Start didn't exist yet, so that project eventually moved to Vite. Start later turned out to be what I'd wanted then.
How much framework-specific knowledge should an engineer need to change an ordinary application feature? I wanted our next framework choice to reduce that learning burden as the team grew.
I had also used Remix. Its emphasis on web primitives was promising, but repeated changes in direction left me with similar concerns about upkeep. I wanted familiar concepts whose behavior would remain understandable as the application and team grew, and a client-first framework to deliver high-quality experiences to users.
An alpha with a familiar programming model
I already trusted TanStack Query. Start assembled primitives I knew, and server functions resembled the typed RPC model I knew from tRPC. There was new integration code to evaluate, but much less of a new programming model to learn.
What if Start didn't work out? My fallback was Vite plus tRPC. If things went wrong, I estimated the refactor to be mostly mechanical since the shape of tRPC procedures and TanStack Start's server functions closely resembled each other. Proper abstraction behind TanStack Query meant only implementation details had to change. I knew most work would be in the build system, deployment, and possibly SSR. Fortunately, it never came to that, so the estimate remains untested.
We've since had a couple of engineers join our Start applications. Thanks to the low barrier to entry, they were productive from day one. The framework was also straightforward for people who hadn't chosen it or established those patterns themselves. Familiar primitives made both joining the project and a potential move away from Start easier to reason about.
What upgrading actually cost
In nearly two years, about four upgrades needed hands-on work. None of
them broke behavior in production, and together they took roughly ten
hours of upkeep. One of them was the move from Vinxi to Vite,
alongside the package rename from
@tanstack/start to @tanstack/react-start.
The work mostly involved updating configuration and moving files, and
was largely mechanical with the help of AI.
There were also some minor points of friction. Sample Vault's initial
code injected
/_build/@react-refresh into the document head and called
RefreshRuntime.injectIntoGlobalHook(window) to get fast refresh
working. I found the snippet in GitHub discussions after investigating
why development updates weren't behaving correctly.
Keeping one application across web and desktop
Most of our work involves client-side SPAs. Sample Vault's main application is almost entirely client-rendered, except for the marketing pages, which use SSR to support SEO. Campaign Finance makes more use of SSR, although it's still primarily a client-side-first app.
Since Sample Vault is a desktop app first, I had to figure out how to include the frontend there. Back then, Start could only serve its client from its own server. There was no static SPA build and no supported way to point server-function calls at another origin. I settled on having the Tauri shell load the hosted remote frontend. Once both were available, we moved to bundling the frontend in March 2026. We now have separate Vite configurations for the web build and the desktop SPA build, sharing application code and server functions.
Sharing the code meant adapting the transport. A bundled client runs
under a Tauri origin, while its server functions still live on our
backend. We provide a custom fetch implementation to Start's
serverFns.fetch. This excerpt resolves the request path
against that backend and passes it through our transport guard:
const customServerFetch: CustomFetch = async (input, init) => {
const rawUrl =
input instanceof URL
? input.href
: typeof input === "string"
? input
: input.url;
const parsed = new URL(rawUrl, window.location.href);
return fetchServerFn(
() => fetchBackend(backendUrl(
`${parsed.pathname}${parsed.search}`
), init),
parsed.pathname,
);
}; backendUrl selects the backend address,
fetchBackend carries our desktop authentication behavior,
and fetchServerFn handles retries and response checks. The
server-function call sites don't each need to know how the desktop client
reaches the server.
Would I split the applications if I started again? No. Tauri's APIs have been straightforward to use, and conditional code where the platforms differ is manageable. Sharing the implementation helps keep features consistent, whereas two independent applications would give us more places for behavior to drift.
Bundling the frontend also turns our server functions into an API that installed clients depend on. A backend deployment doesn't update those clients, so older versions keep calling the old server-function URLs. We have to use stable function IDs, aliases for renamed exports, and retained handlers for older clients. That means a function rename can require compatibility work even when the compiler accepts every change. This is an RPC versioning problem in any stack, but generated endpoints make it easy to forget you're maintaining that contract.
Where does this code run?
Sharing code across environments also brought back a question I'd
struggled with when learning Start. Can a beforeLoad callback
import a browser-only package? Can a loader access the database directly?
The callback's name doesn't tell you its execution environment.
With SSR enabled, beforeLoad and loaders run on the server
for the initial request and in the browser during subsequent navigation.
I needed a few attempts to structure that logic correctly, including failures
from accessing window on the server. The current
execution-model documentation explains the distinction. It wasn't an obvious mental model to me when
I started using it, since it was my first time using TanStack Router.
TanStack Start provides useful abstractions for handling this
efficiently with createIsomorphicFn, createServerOnlyFn, and createClientOnlyFn. The explicit names of these
functions also make the environment where code runs easier to reason
about.
Query's SSR integration was another place where I had to assemble the
pattern. In Campaign Finance, loaders populate the query cache with
ensureQueryData, and components consume those queries,
including through useSuspenseQuery. Awaiting the loader
query lets the route wait for required data and provide data
guarantees to the component. This makes loading states redundant and
simplifies handling missing data.
My difficulty was deciding which data should block navigation, which should load independently, and where Suspense made the behavior easier to follow. Sample Vault's desktop-first SPA has much less reason to involve SSR in those decisions.
Where the types stop
Types check our code, but they can't check what comes back over the network, which is where our worst bugs came from. I generally find Start easier to debug than the frameworks I used before it, but there is one exception: the server-function transport. Requests and responses use a compact serialization format that is difficult to inspect in the browser's network tab. TanStack's devtools help, but moving to another tool interrupts a workflow I already know well.
We hit failures in Sample Vault where application code tried to access
a property of undefined, even though the server
function's inferred return type said the data existed and reading
through the handler gave me no reason to expect it could be absent.
The failure appeared where we consumed the data, well after the
request that caused it.
An inferred return type describes what the handler returns, not what reaches the client. A proxy, CDN or CORS policy can stand in between, and the type system can't see them.
The response headers turned out to matter. Start uses
x-tss-serialized to identify serialized responses. In our
cross-origin Tauri setup, the browser couldn't expose that header to JavaScript
until we included it in
Access-Control-Expose-Headers. Seeing a header in the
network tab doesn't mean your fetch code can read it.
CORS was only part of the problem. What happens when a proxy returns an error before the request reaches your server function? We found error-handling behavior in Start that made those responses look like successful calls with invalid data.
Issue #8280
describes an untagged, non-OK JSON response resolving to
undefined instead of rejecting. In the client code installed
with Sample Vault, the JSON branch returns before the
response.ok check.
Issue #8333
covers another path: an untagged successful non-JSON response can reach
the caller as a raw Response. A proxy returning an HTML
page can therefore cause an apparently unrelated error in a component
expecting application data.
In Sample Vault we added the following guard at the fetch boundary. It accepts Start's tagged responses and separately allows serialized redirects, which legitimately arrive as untagged JSON. Everything else fails with the status, content type, and request path:
if (res.headers.get("x-tss-serialized") || res.headers.get("x-tss-raw")) {
return res;
}
const body = await res.clone().json().catch(() => null);
if (body?.isSerializedRedirect) return res;
throw new Error(
`Untagged server fn response ${res.status} ${res.headers.get("content-type")} ${path}`
);
We also moved network retries into that transport layer. Our earlier
function middleware retried by calling next() again, but the
middleware chain had already been consumed. The second call could resolve
to undefined without sending another request.
Tests now cover an actual retry, serialized redirects, a JSON 522, an unknown-function 500, and a 200 HTML page. AI-written code trusts the types completely, so the boundary has to fail as loudly as a type error would.
I expect to remove this guard once upstream fixes cover these cases. The debugging cost is what concerns me: we were investigating application data when the failure was in the transport, and neither the type signature nor the error told us to look there. If you receive something other than the handler's response, that needs to fail before a component tries to use it.
Would I choose it again?
For these applications, yes. Start gives us integrated routing and typed server calls while leaving room for a predominantly client-side application. I don't need React Server Components for that model, and I wouldn't introduce them without a requirement that justified the additional concepts.
For a mostly static site, I'd use a solution like Astro, as we do here. If an application already has a separate backend and needs only a browser client, plain Vite may be enough. Vite plus tRPC also remains a reasonable alternative when you want to own the integration yourself, which is what I recommend to clients who'd rather rely on more mature tools.
Four upgrades needed roughly ten hours of upkeep. The transport failures were harder to reason about because they sent us looking for mistakes in application data when the problem was elsewhere. When choosing a framework, I want to know how quickly we can find the cause when something breaks, alongside how much work upgrades require.
I'd still choose Start because its client-first approach fits the applications we build. What would make me leave? If that direction changed, or investigating framework behavior became a regular part of delivering features (as it did for us with Next.js), I'd reconsider.