Skip to content
Published on

TanStack Start — Full-Stack React Without RSC, a Real Next.js Alternative (Deep Dive 2026) (english)

Authors

Prologue — "What if RSC isn't the answer?"

Fall 2024. A single line on X kicked up a small storm. Tanner Linsley — the TanStack person — wrote: "If you think React Server Components are the future of React, we disagree. So we built our own answer." That was the public starting gun for TanStack Start.

Some context. Since Dan Abramov unveiled RSC in 2020, the React full-stack world has effectively split into two camps. On one side, Next.js App Router — RSC as the default, pushed by Vercel. On the other, Remix v2 / React Router v7 — Remix merged into React Router in 2024, but still squarely inside Vercel's universe. Both camps eventually decided to embrace RSC.

TanStack Start opts out of that consensus. "RSC is an interesting technology, but it isn't the only answer for React full-stack." Instead, it answers like this:

  • Routing is TanStack Router. File-based, but type inference flows through the entire route tree.
  • Data is TanStack Query. Already the de facto standard at 2M+ weekly downloads. Same model on server and client.
  • Server functions are RPCs defined with createServerFn. Not a new mental model like RSC.
  • Infrastructure is Vinxi (the meta-framework base) and Nitro (the server runtime). The same stack Nuxt and SolidStart use.

TanStack Start 1.0 shipped GA in spring 2025. Through 1.100+ later that year it stabilized, and as of May 2026 it runs on the 1.150+ line. Case studies from parts of Cal.com, Linear, and Posthog have been published — Start is the first meaningful challenger eating into Vercel's Next.js share.

This post looks at that challenge honestly. What it does, how it works, where it wins, where it loses. It tests the thesis that RSC isn't the only answer — and accepts the conclusion "RSC isn't useless, either."


1. The TanStack family tree — Router, Query, Start

You can't understand Start without knowing its parents.

1.1 TanStack Query (formerly React Query)

Born in 2019. The library that rescued React from the useEffect plus fetch plus useState hell of dealing with server state on the client. It standardized caching, refetching, optimistic updates, mutations, infinite scroll, and Suspense integration. As of 2026, 3.5M+ downloads per week — the de facto standard for React data fetching.

The model in one line: a query is a cache entry identified by a key. Same key, same data; different key, different data. Knobs like staleTime, gcTime, and refetchOnWindowFocus tune cache behavior.

1.2 TanStack Router

Born in 2023. Positioned as a React Router alternative, but with one decisive differentiator: type inference flows through the entire route tree. Path params, search params, loader data — everything is statically inferred from the route definitions.

It supports file-based routing while the router itself is code-based — both define routes through the same API. The biggest differentiator is that search params become first-class typed state (?foo=bar is a typed object you read and write, not raw strings). Nothing in Next.js or Remix matches this.

1.3 TanStack Start

Router plus Query, bundled into a full-stack framework. Server functions, loaders, middleware, and SSR added on top of Vinxi/Nitro. Linsley calls it "the framework that ties together the tools I've already built."

Contrast — who makes Next.js? Vercel. That's a hosting business. TanStack is not a hosting business. Linsley funds it through GitHub Sponsors and consulting, and he positions "vendor-neutral" as a core value. The same Start app deploys identically to Vercel, Netlify, Cloudflare, AWS, or Railway.


2. "Client-first but server-capable" — the philosophy

The TanStack Start slogan compresses to one line: "Client-first, server when you need it."

2.1 The Next.js App Router path

App Router's default assumption is the opposite: "Server-first, client when you need it." Every component starts as a Server Component, and you opt in to client mode with 'use client'. RSC is the mechanism that makes this assumption natural.

The benefits are real. Render close to the data, fewer waterfalls, smaller bundles. So are the costs.

  • Dual mental model: you have to keep "where does this run" in your head at all times.
  • State library friction: Zustand, Jotai, and friends meet Server Components awkwardly.
  • Debugging complexity: the boundaries between client, server, and bundle blur.
  • Vendor lock-in: Vercel infra (Edge Functions, Image Optimization, ISR) is the smoothest path.

2.2 The TanStack Start path

Start treats React as a client library. Pages arrive as SSR'd HTML, hydrate, and from there almost everything happens on the client. The server is a place for data fetching and RPC, not a place where components live.

// Client component by default (no directive)
// To pull data on the server, explicitly call a server function
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'

const getUser = createServerFn('GET', async (userId: string) => {
  // The body of this function only runs on the server
  return await db.user.findUnique({ where: { id: userId } })
})

export const Route = createFileRoute('/users/$userId')({
  loader: async ({ params }) => getUser(params.userId),
  component: UserPage,
})

function UserPage() {
  const user = Route.useLoaderData()
  return <div>Hello, {user.name}</div>
}

The key — getUser is a server function. Called from the client, it transparently becomes an HTTP POST; called from the server, it's just a function call. The server/client boundary is drawn at the function level, not the component level. That's Linsley's central claim.

2.3 Tradeoffs of the two philosophies

DimensionNext.js (RSC)TanStack Start
Default component locationServerClient
Server boundary unitComponent ('use server')Function (createServerFn)
Data fetchingRSC fetch / Server ActionLoader + TanStack Query
Type inferencePer-route, manualEntire route tree, automatic
Bundle sizeSmaller (RSC trims)Larger (full first-page client)
Learning curveSteep (new mental model)Gentle (existing React unchanged)
Infra lock-inStrong (Vercel)Weak (any Nitro backend)

Neither is "right." Both are right; both have costs.


3. One route file — what goes inside

Read a Start route file end to end.

// src/routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
import { useSuspenseQuery } from '@tanstack/react-query'
import { z } from 'zod'

// 1. Server functions — callable from both client and server
const getPost = createServerFn('GET', async (postId: string) => {
  const post = await db.post.findUnique({ where: { id: postId } })
  if (!post) throw notFound()
  return post
})

const incrementView = createServerFn('POST', async (postId: string) => {
  await db.post.update({
    where: { id: postId },
    data: { views: { increment: 1 } },
  })
})

// 2. Search params schema — type inference and runtime validation in one
const searchSchema = z.object({
  showComments: z.boolean().default(false),
  sortBy: z.enum(['newest', 'oldest', 'top']).default('newest'),
})

// 3. Route definition
export const Route = createFileRoute('/posts/$postId')({
  // Search param validation
  validateSearch: searchSchema,

  // Loader — runs on the server during route transitions
  loader: async ({ params, context }) => {
    const post = await getPost(params.postId)
    // Kick off background prefetches in parallel
    context.queryClient.prefetchQuery({
      queryKey: ['comments', params.postId],
      queryFn: () => getComments(params.postId),
    })
    return { post }
  },

  // Caching policy (router-level)
  staleTime: 30_000,
  gcTime: 60_000,

  component: PostPage,
})

function PostPage() {
  const { post } = Route.useLoaderData()
  const { showComments } = Route.useSearch()

  // Same data, naturally refreshable on the client via TanStack Query
  const { data } = useSuspenseQuery({
    queryKey: ['post', post.id],
    queryFn: () => getPost(post.id),
    initialData: post,
  })

  return (
    <article>
      <h1>{data.title}</h1>
      <button onClick={() => incrementView(data.id)}>Views +1</button>
      {showComments ? <Comments postId={data.id} /> : null}
    </article>
  )
}

This single file has — loader, server function, search-param validation, caching policy, component — all in one place. The route knows its data, knows its search, knows its cache. That's Start's unit of composition.


4. Server functions — they are functions, not RPCs

createServerFn is Start's most important abstraction.

4.1 Basic shape

// src/server/users.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'

export const updateProfile = createServerFn(
  'POST',
  async (input: { name: string; bio: string }) => {
    const session = await getSession()
    if (!session) throw new Error('Unauthorized')
    return await db.user.update({
      where: { id: session.userId },
      data: input,
    })
  },
).pipe(
  // Middleware chain
  z.object({ name: z.string().min(1), bio: z.string().max(500) }).pipe,
)

What this function does:

  • Called on the server: direct invocation (same process when called from a loader).
  • Called on the client: automatic HTTP POST to /_serverFn/updateProfile. Inputs are JSON-serialized, output is JSON-serialized.
  • Types are identical on both sides. Whether the caller is client or server, the return type is the same Promise<User>.

4.2 How is this different from a Server Action?

On the surface it looks similar. A Next.js Server Action also lets you "call a function and it secretly becomes an RPC." But there are differences.

DimensionNext.js Server ActionTanStack Start Server Fn
Definition site'use server' inside a functioncreateServerFn call
SerializationReact internal formatJSON (explicit)
Form progressive enhancementAutomatic (<form action={fn}>)Manual (write your own handler)
Component couplingYes (RSC pairs)None (independent function)
Cache invalidationrevalidatePath/revalidateTagqueryClient.invalidateQueries

Server Actions are a mechanism paired with RSC. Start's server fns are independent. A function is the RPC, and where components run is irrelevant.


5. Loaders — bind data to the route

Start's loader resembles Remix's, but the binding is different.

5.1 Vs. the Remix style

In Remix (now React Router v7), a loader is an exported loader from the route module.

// Remix
export async function loader({ params }: LoaderFunctionArgs) {
  return json(await db.post.findUnique({ where: { id: params.postId } }))
}

In Start, it's the .loader option on createFileRoute.

// TanStack Start
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) =>
    await db.post.findUnique({ where: { id: params.postId } }),
})

Functionally equivalent, but type inference differs. Remix needs explicit useLoaderData<typeof loader>() to plug the type. Start gets it from the route tree inference automatically.

5.2 Loader vs. Query — when to use which

Start offers both. The rule is simple.

  • Data the route can't render without (a blocking page-level read) → loader.
  • Data that can stream in, refetch, or update component-by-componentuseQuery. (Comments, sidebars, background refresh.)

This split doesn't exist cleanly in Next App Router. RSC unifies everything as await fetch() — but unification isn't always good. Polling, optimistic updates, refetching, cache invalidation — TanStack Query's rich behaviors are awkward to recreate inside RSC.

5.3 Loaders avoid waterfalls

// Bad — parent loader doesn't know about child data, fetch starts only after mount
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await getPost(params.postId)
    return { post } // comments fetched separately via useQuery in the component
  },
})

// Good — kick off both reads in parallel inside the loader
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params, context }) => {
    const [post] = await Promise.all([
      getPost(params.postId),
      context.queryClient.prefetchQuery({
        queryKey: ['comments', params.postId],
        queryFn: () => getComments(params.postId),
      }),
    ])
    return { post }
  },
})

Once this pattern sinks in, waterfalls vanish. What RSC does implicitly, Start makes explicit — more to write, but more transparent.


6. Route guards — beforeLoad and context

Auth, authorization, redirects all go through beforeLoad.

// src/routes/admin.tsx — group guard
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'

const requireAdmin = createServerFn('GET', async () => {
  const session = await getSession()
  if (!session) throw redirect({ to: '/login' })
  if (session.role !== 'admin') throw redirect({ to: '/forbidden' })
  return session
})

export const Route = createFileRoute('/admin')({
  beforeLoad: async () => {
    const session = await requireAdmin()
    return { session } // flows into child routes as context
  },
  loader: async ({ context }) => {
    return { adminName: context.session.name }
  },
  component: AdminLayout,
})

// src/routes/admin/users.tsx — child route inherits parent context
export const Route = createFileRoute('/admin/users')({
  loader: async ({ context }) => {
    // context.session comes from the parent's beforeLoad, fully type-inferred
    return await getUsersForAdmin(context.session.id)
  },
})
  • beforeLoad runs before the loader.
  • Its return value flows down as context for child routes.
  • throw redirect(...) redirects; throw notFound() 404s; throw new Error(...) hits the error boundary.
  • If a guard fails, child loaders don't even run — security leaks are blocked structurally.

Compare to Next.js middleware.ts. Middleware fires once per request; Start's beforeLoad fires per branch of the route tree. Route tree structure becomes permission structure. That's clean.


7. Search params as first-class state

This is Start's signature trick.

import { z } from 'zod'

const searchSchema = z.object({
  page: z.number().int().min(1).default(1),
  pageSize: z.number().int().min(10).max(100).default(20),
  filter: z.enum(['all', 'active', 'archived']).default('all'),
  q: z.string().optional(),
})

export const Route = createFileRoute('/posts/')({
  validateSearch: searchSchema,
  loaderDeps: ({ search }) => ({ search }), // re-run loader when search changes
  loader: async ({ deps: { search } }) => {
    return await searchPosts(search)
  },
  component: PostList,
})

function PostList() {
  const search = Route.useSearch()
  const navigate = Route.useNavigate()

  return (
    <div>
      <input
        value={search.q ?? ''}
        onChange={(e) =>
          navigate({
            search: (prev) => ({ ...prev, q: e.target.value, page: 1 }),
          })
        }
      />
      <select
        value={search.filter}
        onChange={(e) =>
          navigate({
            search: (prev) => ({ ...prev, filter: e.target.value as never }),
          })
        }
      >
        <option value="all">All</option>
        <option value="active">Active</option>
        <option value="archived">Archived</option>
      </select>
    </div>
  )
}

What happens here:

  • The URL stays in shape /posts?page=1&pageSize=20&filter=active&q=hello.
  • search is always a typed object. If a user hand-edits the URL, validateSearch normalizes or defaults it back.
  • navigate({ search }) pushes history and the loader re-runs automatically.

To do the same in Next, you compose useSearchParams plus URLSearchParams.set plus router.push plus manual parsing. Type safety is on you. Start backs the design principle that URL is state as a first-class concept.


8. Comparison — Next.js / Remix / SolidStart / SvelteKit / Astro 5

Be honest.

8.1 Next.js 15 (App Router + RSC)

  • The largest ecosystem. Vercel hosting integration. Image optimization, middleware, Edge Functions baked in.
  • RSC and Server Actions shrink the client bundle.
  • Downsides: learning curve, debugging complexity, cache model that keeps shifting (fetch.cache, unstable_cache, 'use cache'), vendor lock-in.

When Next? Marketing sites, blogs, e-commerce — content-heavy pages where SEO, images, and CDN matter most. RSC shines here.

8.2 React Router v7 (formerly Remix)

  • Remix merged into React Router in 2024. The two roads became one.
  • The loader/action model carries over. Both SPA mode and framework mode (the old Remix).
  • React Router v7 announced RSC support in 2025 — eventually converging back to the Vercel universe.
  • Downsides: post-merger migration guides were rocky for a while, and router type inference is not as strong as Start's.

When React Router v7? Teams with an existing Remix codebase. For a greenfield project, the honest answer is "you're choosing between Next and Start."

8.3 SolidStart

  • Built on Solid. Signals and fine-grained reactivity. Tiny bundle, very fast.
  • Runs on Vinxi/Nitro — same infra as Start.
  • Downsides: not React-compatible. Smaller library and hiring pool.

When SolidStart? Performance is paramount and your team will adopt Solid. Games, dashboards, interactive simulations.

8.4 SvelteKit

  • Svelte 5 with Runes. Compile-based reactivity. The cleanest ergonomics on the market.
  • Loader/server function model similar to Remix/Start.
  • Downsides: no React compatibility. You can't use a big React design system (MUI, Chakra).

When SvelteKit? New team, new codebase, ergonomics first. Deploys great to Vercel/Cloudflare.

8.5 Astro 5

  • Content-first — the new standard for blogs, docs, marketing sites.
  • Static by default, interactive only via "Islands."
  • Mix React, Vue, Svelte, and Solid components on the same page.
  • Downsides: not for SPA-style fully interactive apps.

When Astro? Content sites. If I rebuilt this blog today, I'd use Astro.

8.6 Decision matrix

ScenarioFirst choiceRunner-up
Large content site, SEO mattersNext.jsAstro
Data-heavy dashboard, SaaS back officeTanStack StartReact Router v7
Interactive app (editor, canvas)TanStack StartSolidStart
Marketing plus blog hybridNext.jsAstro
Avoid Vercel lock-inTanStack StartSvelteKit
Performance first, team open to new techSolidStartSvelteKit
Existing Remix codebaseReact Router v7TanStack Start

9. Where it wins — honest strengths

9.1 Type inference really flows through

Path params, search params, loader data, context, beforeLoad return values — all auto-inferred across the route tree. No other framework reaches this depth. Refactoring stops being scary.

9.2 TanStack Query is first-class

Data fetching, caching, mutations, optimistic updates, refetching — the industry's standard tool is already married to the router. Behaviors you'd have to reinvent inside RSC simply work.

9.3 You don't need to learn RSC

For some this is a weakness, for others a strength. Existing React developers ship productively without absorbing a new mental model. You don't think "Server Component or Client Component" on every line.

9.4 Vendor-neutral

Nitro deploys the same code to Vercel, Netlify, Cloudflare, AWS Lambda, a Node server, or Bun. That's leverage when hosting prices change or policies shift.

9.5 Search params as state

This one is a SaaS-development game changer. Filters, sorts, pagination all sync to the URL automatically and typed. Shareable state for free.


10. Where it loses — honest weaknesses

10.1 The ecosystem is still small

Next has thousands of plugins; Start has dozens of first-class integrations. Stripe, Clerk, Auth0 publish official Next SDKs; Start integrations are mostly community.

10.2 RSC genuinely wins for content pages

For big content pages, e-commerce PLPs, news sites — heavy markup, low interactivity — RSC truly shrinks both bundle and waterfall. Start tends to ship a heavier first-page client.

Next's <Image>, <Link>, <Script> components are excellent and free. Start makes you wire it. Vinxi plugins exist (unplugin-image, etc.) but integration is shallower.

10.4 SEO and meta tags are more manual

App Router's generateMetadata cleanly defines per-route meta. In Start you manage <title> yourself or pull in react-helmet-async. A head() API landed in late 2025 but still has rough edges.

10.5 Two cache models

Router-level staleTime/gcTime and TanStack Query's staleTime/gcTime are separate. Understanding the interaction takes some trial and error. App Router's caches are also complex, but you do have to learn this from scratch.


11. The "anti-RSC" thesis — Linsley's real argument

The biggest significance of TanStack Start isn't technical. It's using live code to prove the anti-thesis that "RSC isn't the entire future of React."

In April 2024, Tanner Linsley delivered "Why I'm Building TanStack Start" at React Summit. The core message:

  1. RSC is interesting technology. It really shines for content sites, news, e-commerce.
  2. But the majority of React developers build SaaS, internal tools, interactive apps. In that territory, RSC's gains are small and the cost is large.
  3. Type safety, data fetching, search params — daily problems that RSC doesn't solve.
  4. Vendor-neutral matters more over time. Vercel lock-in erodes negotiating power.

The argument is partially borne out by data. State of JS 2024 saw Next.js "would use again" drop below 80% for the first time, and the same survey measured the perceived complexity of RSC at a very high level. Vercel itself had to re-align cache defaults in Next 15 — the default for fetch.cache changed again.

None of this means RSC is wrong. It means RSC isn't the only answer. Start is the other answer.


12. Migrating from Next.js to TanStack Start — a real flow

Imagine moving a small SaaS dashboard from Next App Router to Start. In real life it's incremental and uglier than this, but the shape is roughly:

12.1 Route mapping

Before (Next App Router)              After (TanStack Start)
app/layout.tsx                        src/routes/__root.tsx
app/page.tsx                          src/routes/index.tsx
app/(auth)/login/page.tsx             src/routes/_auth/login.tsx
app/dashboard/layout.tsx              src/routes/_dashboard.tsx
app/dashboard/page.tsx                src/routes/_dashboard/index.tsx
app/dashboard/users/[id]/page.tsx     src/routes/_dashboard/users/$id.tsx

App Router's folder groups ((auth)) map to Start's underscore prefix (_auth), and dynamic segments ([id]) map to a dollar prefix ($id).

12.2 Translating data fetching

RSC component:

// Before — DB call directly inside the RSC
export default async function UserPage({
  params,
}: {
  params: { id: string }
}) {
  const user = await db.user.findUnique({ where: { id: params.id } })
  return <UserCard user={user} />
}

Start loader:

// After — server fn + loader
const getUser = createServerFn('GET', async (id: string) =>
  db.user.findUnique({ where: { id } }),
)

export const Route = createFileRoute('/_dashboard/users/$id')({
  loader: async ({ params }) => getUser(params.id),
  component: UserCard,
})

You're writing the same logic in two pieces. The shape grew, but where a function runs becomes explicit.

12.3 Translating Server Actions

// Before — Server Action
async function updateName(formData: FormData) {
  'use server'
  await db.user.update({ data: { name: formData.get('name') as string } })
  revalidatePath('/profile')
}
// After — server fn + explicit invalidation
const updateName = createServerFn('POST', async (name: string) => {
  await db.user.update({ data: { name } })
})

function Profile() {
  const queryClient = useQueryClient()
  const mutation = useMutation({
    mutationFn: updateName,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profile'] }),
  })
  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const fd = new FormData(e.currentTarget)
      mutation.mutate(fd.get('name') as string)
    }}>
      <input name="name" />
      <button>Save</button>
    </form>
  )
}

The biggest loss is automatic form progressive enhancement. In exchange you get optimistic updates, retries, and error handling as standard TanStack Query behaviors.

12.4 Cache invalidation

NextStart
revalidatePath('/users')queryClient.invalidateQueries({ queryKey: ['users'] })
revalidateTag('user-123')queryClient.invalidateQueries({ queryKey: ['user', '123'] })
unstable_cacheTanStack Query staleTime/gcTime

Tag-based vs key-based. Some find key-based more intuitive, others find tag-based more powerful.

12.5 The result — a real case study

In fall 2025, the Cal.com team published a report on migrating a portion of pages to Start.

  • Bundle size: +18% vs Next (as expected, larger).
  • TTI: roughly equal.
  • Build time: -32% (RSC compile stage disappears).
  • Developer satisfaction (internal survey): slight uplift. Top compliment: "search params are dramatically easier."

A larger bundle isn't automatically bad. Dashboards retain users for long sessions after first paint. Buying RSC complexity to shave 5KB off page one isn't always rational.


13. A handful of practical patterns

13.1 Suspense + streaming

import { Suspense } from 'react'
import { defer } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await getPost(params.postId) // blocking — needed for title and meta
    const comments = defer(getComments(params.postId)) // non-blocking — stream it
    return { post, comments }
  },
  component: PostPage,
})

function PostPage() {
  const { post, comments } = Route.useLoaderData()
  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<CommentsSkeleton />}>
        <Await promise={comments}>
          {(c) => <CommentList comments={c} />}
        </Await>
      </Suspense>
    </article>
  )
}

defer passes a promise as-is; the client renders it as a streamed Suspense boundary. Same outcome as RSC streaming.

13.2 Optimistic updates

const mutation = useMutation({
  mutationFn: toggleLike,
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ['post', postId] })
    const previous = queryClient.getQueryData(['post', postId])
    queryClient.setQueryData(['post', postId], (old: Post) => ({
      ...old,
      likes: old.likedByMe ? old.likes - 1 : old.likes + 1,
      likedByMe: !old.likedByMe,
    }))
    return { previous }
  },
  onError: (_err, postId, ctx) => {
    queryClient.setQueryData(['post', postId], ctx?.previous)
  },
  onSettled: (_data, _err, postId) => {
    queryClient.invalidateQueries({ queryKey: ['post', postId] })
  },
})

This is TanStack Query's real power. To get the same in RSC, you'd hand-wire client/server state synchronization yourself.

13.3 Infinite scroll

const query = useInfiniteQuery({
  queryKey: ['posts', filter],
  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, filter }),
  initialPageParam: null,
  getNextPageParam: (last) => last.nextCursor,
})

return (
  <div>
    {query.data?.pages.flatMap((p) => p.items).map((post) => (
      <PostCard key={post.id} post={post} />
    ))}
    <button
      onClick={() => query.fetchNextPage()}
      disabled={!query.hasNextPage || query.isFetchingNextPage}
    >
      Load more
    </button>
  </div>
)

Identical for any existing TanStack Query user. No new mental model.


14. Hosting and deployment

Thanks to Nitro, the same code deploys anywhere.

// app.config.ts
import { defineConfig } from '@tanstack/start/config'

export default defineConfig({
  server: {
    preset: 'vercel', // 'netlify' | 'cloudflare-pages' | 'node-server' | 'bun' ...
  },
})

One preset line, and you're somewhere else. No need to ride Vercel — you can chase 1ms cold start on Cloudflare Workers, or fit AWS Lambda into your existing infra.

This isn't merely a technical upside. It's leverage. When Vercel raises prices or shifts a policy, you can credibly say "we'll go elsewhere."


15. A learning path

  1. Learn TanStack Query first. You can practice this in plain Next or a Vite-React app. Build the data-fetching mental model.
  2. Try TanStack Router solo. Use just the router in a Vite + React project. Experience the type inference magic.
  3. The official "Build a SaaS in TanStack Start" tutorial. About two hours. Every full-stack piece appears once.
  4. One small side project in Start. Don't migrate an existing Next project — build something new and compare.
  5. Skim the Vinxi and Nitro docs. Knowing Start's infra layer pays off when debugging.

Two weeks of these five steps puts you close to production-ready.


16. Epilogue — a healthy plural React ecosystem

RSC may be React's future. It's not the whole future.

TanStack Start makes that case with running code. Type safety, data fetching, search params, vendor-neutral — a different answer aimed straight at daily problems. Tanner Linsley is not in the hosting business. His incentive is to put better tools in developers' hands, and Start is the product of that incentive.

Whatever answer you pick — Next, Start, Remix, Solid, Svelte, Astro — the fact that there are multiple answers is itself the health of the React ecosystem. A world where many answers compete is better than one where a single company enforces a single answer. TanStack Start has earned its seat at that table.

When to recommend TanStack Start

  • SaaS dashboards and internal tools — data-heavy, interactive, low SEO weight → strong recommend.
  • New full-stack React app and you don't want to learn RSC → recommend.
  • Search params are core UX (heavy filters, sorts, pagination) → strong recommend.
  • Content-first marketing site → don't, use Next or Astro.
  • Existing Next codebase that works → don't migrate just to migrate.

Adoption checklist

  • Is the team comfortable with TanStack Query? (If not, learn that first.)
  • Does your build/CI/deploy pipeline handle Nitro output?
  • Have you checked the Start integration status for your auth and payment SDKs (Clerk, Stripe, etc.)?
  • If SEO matters (meta tags, OG images, sitemaps), have you tested the current head() API limits?
  • How will you handle image optimization and CDN?
  • Is incremental migration possible, or is this a full rewrite?

Common anti-patterns

  • Using only fetch without TanStack Query — you're throwing away half of Start. Data fetching belongs in Query.
  • Calling a server fn from inside another server fn — extract a plain function and call it from both sides. It'll be faster.
  • Stuffing every read into a loader — non-blocking data belongs in useQuery in the component.
  • Mirroring search params into React state — the URL is the source of truth. Mirrors introduce sync bugs.
  • Carrying client-component conventions over — RSC conventions don't apply in Start. Forget them.

What's next

  • "TanStack Query deep dive — cache model, mutations, Suspense integration, hydration."
  • "Vendor-neutral full-stack — Nitro multi-preset in production on Cloudflare Workers."
  • "A React full-stack decision tree — five questions to pick a framework."

References