LabHub

Blog

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

한국어English日本語

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:

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.

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:

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.

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)
  },
})

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:

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)

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)

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

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

8.4 SvelteKit

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

8.5 Astro 5

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.

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

Adoption checklist

Common anti-patterns

What's next


References

Comments

No comments yet.

Sign in to leave a comment