Skip to content

Split View: TanStack Start — RSC 없이도 풀스택 React를 한다, Next.js의 대안 깊게 보기 2026

|

TanStack Start — RSC 없이도 풀스택 React를 한다, Next.js의 대안 깊게 보기 2026

프롤로그 — "RSC가 정답이 아닐 수도 있다"

2024년 가을, X에서 어떤 한 줄이 작은 폭풍을 일으켰다. Tanner Linsley — TanStack의 그 사람 — 가 이렇게 적었다. "React Server Components가 React의 미래가 맞다고? 우리는 동의하지 않는다. 그래서 우리만의 답을 만들었다." 그게 TanStack Start의 공개 신호탄이었다.

배경부터 정리하자. 2020년 Dan Abramov가 RSC를 공개한 뒤로 React 생태계의 풀스택은 사실상 두 갈래로 정렬됐다. 한쪽은 Next.js App Router — RSC를 기본값으로 받아들이고 Vercel이 밀어붙이는 길. 다른 한쪽은 Remix v2 / React Router v7 — 2024년 Remix가 React Router에 합병되며 단일 길이 된, 그러나 여전히 Vercel의 우주 안에 있는 길. 두 길 모두 결국 RSC를 품기로 했다.

TanStack Start는 그 합의에서 빠져나왔다. "RSC는 흥미로운 기술이지만 React 풀스택의 유일한 답은 아니다." 대신 이렇게 답한다.

  • 라우팅은 TanStack Router. 파일 기반이지만 타입 추론이 라우트 트리 끝까지 흐른다.
  • 데이터는 TanStack Query. 이미 200만 다운로드/주의 사실상 표준. 서버에서도 클라이언트에서도 같은 모델.
  • 서버 함수는 createServerFn으로 정의한 RPC. RSC 같은 새 멘탈 모델이 아니다.
  • 인프라는 Vinxi(메타-프레임워크 기반)와 Nitro(서버 런타임). Nuxt·SolidStart가 이미 쓰는 것들.

2025년 봄 TanStack Start 1.0이 GA로 찍혔다. 2025년 후반 1.100+을 거치며 안정화됐고, 2026년 5월 현재 1.150+ 라인으로 굴러간다. Cal.com, Linear, Posthog의 일부 페이지가 채택했다는 케이스 스터디가 공개됐고, Vercel의 Next.js 점유율을 잠식하는 의미 있는 첫 도전자다.

이 글은 그 도전을 정직하게 본다. 무엇을 하는가, 어떻게 동작하는가, 어디서 이기고 어디서 진다. RSC가 만능이 아니라는 명제를 검증하면서, 동시에 "그래서 RSC가 필요 없다는 뜻은 아니다"라는 결론도 함께 받아들인다.


1장 · TanStack의 가계도 — Router, Query, Start

TanStack Start를 이해하려면 그 부모부터 봐야 한다.

1.1 TanStack Query (구 React Query)

2019년 등장. 클라이언트에서 서버 상태를 다루는 — useEffect + fetch + useState 지옥에서 React를 구원한 — 라이브러리. 캐싱·재요청·낙관적 업데이트·뮤테이션·무한 스크롤·서스펜스 통합까지 모두 표준화했다. 2026년 현재 주당 다운로드 350만+, React 데이터 페칭의 사실상 표준.

핵심 모델 한 줄: 쿼리는 키로 식별되는 캐시 엔트리다. 같은 키는 같은 데이터, 다른 키는 다른 데이터. staleTime·gcTime·refetchOnWindowFocus 같은 노브가 캐시 동작을 조정한다.

1.2 TanStack Router

2023년 등장. React Router의 대안을 표방했지만 결정적인 차별점이 하나 있다. 타입 추론이 라우트 트리 전체에 흐른다. 라우트 경로에서 params·search·loaderData까지 모든 게 정적으로 추론된다.

파일 기반 라우팅을 지원하면서도 라우터 자체는 코드 기반 — 둘 다 같은 API로 정의한다. 검색 파라미터(?foo=bar)를 타입드 상태로 1급 시민으로 다룬다는 점이 가장 큰 차별점. 이건 Next.js나 Remix 어디에도 없는 기능이다.

1.3 TanStack Start

Router + Query를 풀스택 프레임워크로 묶은 것. Vinxi/Nitro 위에 서버 함수·로더·미들웨어·SSR을 더했다. Tanner Linsley는 "내가 만든 도구들을 하나의 프레임워크로 엮은 것"이라고 말한다.

대조 — Next.js를 누가 만들었나? Vercel. 그건 호스팅 사업이다. TanStack은 호스팅 사업이 아니다. Linsley는 GitHub Sponsors와 컨설팅으로 살고, "vendor-neutral"을 핵심 가치로 내세운다. 같은 Start 앱이 Vercel·Netlify·Cloudflare·AWS·Railway 어디든 똑같이 배포된다.


2장 · "client-first but server-capable" — 철학의 핵심

TanStack Start의 슬로건은 한 줄로 압축된다. "클라이언트 우선, 서버는 필요한 만큼."

2.1 Next.js App Router의 길

App Router의 기본 가정은 거꾸로다. "서버 우선, 클라이언트는 필요한 만큼." 모든 컴포넌트가 Server Component로 시작하고 'use client'를 붙여야 클라이언트가 된다. RSC는 이 가정을 자연스럽게 만들기 위한 메커니즘이다.

장점은 분명하다. 데이터 가까이서 렌더링 → 워터폴 감소 → 번들 크기 감소. 그러나 단점도 분명하다.

  • 이중 멘탈 모델: 컴포넌트가 어디서 도는지 항상 의식해야 한다.
  • 상태 라이브러리 호환성 문제: Zustand·Jotai 같은 클라이언트 상태가 서버 컴포넌트와 어색하게 만난다.
  • 디버깅 복잡도: 클라이언트·서버·번들 경계가 흐려진다.
  • 벤더 락인: Vercel의 인프라(Edge Function·Image Optimization·ISR)에 강하게 묶인다.

2.2 TanStack Start의 길

Start는 React를 클라이언트 라이브러리로 본다. 페이지가 SSR된 HTML로 도착하고, hydrate되며, 이후엔 거의 모든 게 클라이언트에서 일어난다. 서버는 데이터 페치와 RPC를 위한 곳이지 컴포넌트가 사는 곳이 아니다.

// 클라이언트 컴포넌트가 기본 (지시어 없음)
// 서버에서 데이터를 끌어오려면 명시적으로 server function을 호출
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'

const getUser = createServerFn('GET', async (userId: string) => {
  // 이 함수의 본문은 서버에서만 실행됨
  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>
}

핵심 차이 — getUser는 서버 함수다. 클라이언트가 호출하면 자동으로 HTTP POST가 발생하고, 서버에서 호출하면 그냥 함수 호출이다. RSC처럼 컴포넌트 단위가 아니라 함수 단위로 서버/클라 경계를 그린다. 이게 Linsley의 핵심 주장이다.

2.3 두 철학의 트레이드오프

측면Next.js (RSC)TanStack Start
기본 컴포넌트 위치서버클라이언트
서버 경계 단위컴포넌트('use server')함수(createServerFn)
데이터 페칭RSC fetch / Server ActionLoader + TanStack Query
타입 추론라우트 별 수동라우트 트리 전체 자동
번들 크기RSC 덕에 작아짐큼 (클라이언트 첫 페이지 전체)
학습 곡선가파름 (새 멘탈 모델)완만함 (기존 React 그대로)
인프라 락인Vercel 강함약함 (Nitro 백엔드 어디든)

어느 쪽이 옳다는 게 아니다. 둘 다 옳고 둘 다 비용이 있다.


3장 · 라우트 파일 한 장 — 무엇이 들어가는가

Start의 라우트 파일을 한 장 통째로 본다.

// 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. 서버 함수 — 클라이언트와 서버 양쪽에서 호출 가능
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. 검색 파라미터 스키마 — 타입 추론과 런타임 검증을 동시에
const searchSchema = z.object({
  showComments: z.boolean().default(false),
  sortBy: z.enum(['newest', 'oldest', 'top']).default('newest'),
})

// 3. 라우트 정의
export const Route = createFileRoute('/posts/$postId')({
  // 검색 파라미터 유효성
  validateSearch: searchSchema,

  // 로더 — 라우트 전이 시 서버에서 실행
  loader: async ({ params, context }) => {
    const post = await getPost(params.postId)
    // 백그라운드 액션도 같이 트리거
    context.queryClient.prefetchQuery({
      queryKey: ['comments', params.postId],
      queryFn: () => getComments(params.postId),
    })
    return { post }
  },

  // 캐싱 정책 (Router 레벨)
  staleTime: 30_000,
  gcTime: 60_000,

  component: PostPage,
})

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

  // 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)}>조회수 +1</button>
      {showComments ? <Comments postId={data.id} /> : null}
    </article>
  )
}

이 한 장에 — 로더, 서버 함수, 검색 파라미터 검증, 캐싱 정책, 컴포넌트가 — 모두 들어 있다. 라우트가 자기 데이터를 알고, 자기 검색을 알고, 자기 캐시를 안다. 이게 Start의 단위다.


4장 · 서버 함수 — RPC가 아니라 함수다

createServerFn은 Start의 가장 중요한 추상이다.

4.1 기본 모양

// 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(
  // 미들웨어 체인
  z.object({ name: z.string().min(1), bio: z.string().max(500) }).pipe,
)

이 함수는:

  • 서버에서 호출하면: 그냥 직접 실행 (로더에서 부르면 같은 프로세스).
  • 클라이언트에서 호출하면: 자동으로 HTTP POST /_serverFn/updateProfile 발생. 입력은 JSON 직렬화, 출력도 JSON 직렬화.
  • 타입은 양쪽 다 동일. 클라이언트가 호출하든 서버가 호출하든 같은 Promise<User>를 받는다.

4.2 RSC의 Server Action과 무엇이 다른가

표면적으로는 비슷하다. Next.js의 Server Action도 "함수처럼 부르면 자동으로 RPC가 된다." 그러나 차이가 있다.

측면Next.js Server ActionTanStack Start Server Fn
정의 위치'use server' 함수 안createServerFn 호출로
직렬화React가 내부 포맷JSON (명시적)
폼 진보적 향상자동 (<form action={fn}>)수동 (직접 핸들러 작성)
컴포넌트 의존있음 (RSC와 짝)없음 (독립 함수)
캐시 무효화revalidatePath/revalidateTagqueryClient.invalidateQueries

Server Action은 RSC와 짝을 이루는 메커니즘이다. Start의 server fn은 독립적이다. 함수가 곧 RPC라는 모델만 남고, 컴포넌트가 어디서 도는지는 전혀 무관하다.


5장 · 로더 — 데이터를 라우트에 묶는다

Start의 로더는 Remix의 로더와 비슷하지만 결합 방식이 다르다.

5.1 Remix 스타일과의 비교

Remix(현 React Router v7)에서 로더는 라우트 모듈의 loader export다.

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

Start에서는 createFileRoute(...).loader 옵션이다.

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

기능적으로 동일하지만 타입 추론이 다르다. Remix는 useLoaderData<typeof loader>()로 명시적 타입 매핑이 필요한데, Start는 Route.useLoaderData()가 라우트 트리 추론에서 자동으로 정확한 타입을 받는다.

5.2 로더 vs Query — 언제 무엇을 쓰는가

Start는 둘 다 제공한다. 규칙은 단순하다.

  • 라우트 전이 시 차단 로딩이 필요한 데이터loader. (페이지가 데이터 없이 보여선 안 되는 경우.)
  • 컴포넌트 단위로 비차단 페치/갱신이 필요한 데이터useQuery. (덧글, 사이드바, 백그라운드 새로고침.)

이 분리가 Next.js App Router에는 없다. RSC에서는 모두 await fetch()로 통합되지만, 그게 항상 좋은 건 아니다. 폴링·낙관적 업데이트·재요청·캐시 무효화 — TanStack Query의 풍부한 동작은 RSC에서 다시 구현하기 어렵다.

5.3 로더는 워터폴을 피한다

// Bad — 부모 로더가 자식 데이터를 모름, 자식이 마운트되고 나서야 페치 시작
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await getPost(params.postId)
    return { post } // 댓글은 컴포넌트에서 useQuery로 따로 페치
  },
})

// Good — 로더에서 둘 다 병렬 페치 시작
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 }
  },
})

이 패턴이 손에 익으면 워터폴은 자연스럽게 사라진다. RSC가 자동으로 해주는 일을 Start에서는 명시적으로 한다 — 더 쓸 게 많지만 더 투명하다.


6장 · 라우트 가드 — beforeLoad와 컨텍스트

인증·인가·리다이렉트는 beforeLoad로 한다.

// src/routes/admin.tsx — 라우트 그룹 가드
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 } // 컨텍스트로 자식 라우트에 흐름
  },
  loader: async ({ context }) => {
    return { adminName: context.session.name }
  },
  component: AdminLayout,
})

// src/routes/admin/users.tsx — 자식 라우트는 부모 컨텍스트를 받는다
export const Route = createFileRoute('/admin/users')({
  loader: async ({ context }) => {
    // context.session은 부모의 beforeLoad에서 흘러옴, 타입 추론 자동
    return await getUsersForAdmin(context.session.id)
  },
})
  • beforeLoad로더보다 먼저 실행된다.
  • 반환값은 자식 라우트의 context로 흐른다.
  • throw redirect(...)로 리다이렉트, throw notFound()로 404, throw new Error(...)로 에러 바운더리.
  • 가드 실패 시 자식 로더는 아예 실행되지 않는다 — 보안 누수가 구조적으로 막힌다.

Next.js의 middleware.ts와 비교하면 — 미들웨어는 모든 요청에 대해 한 번 실행되지만 Start의 beforeLoad라우트 트리의 단위로 실행된다. 라우트 트리 구조가 곧 권한 구조가 된다. 이게 깔끔하다.


7장 · 검색 파라미터를 1급 시민으로

이건 Start만의 자랑이다.

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 }), // 검색 바뀌면 로더 재실행
  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">전체</option>
        <option value="active">활성</option>
        <option value="archived">보관</option>
      </select>
    </div>
  )
}

여기서 일어나는 일:

  • URL은 /posts?page=1&pageSize=20&filter=active&q=hello 형태로 유지된다.
  • search는 항상 타입드 객체. 사용자가 손으로 URL을 망쳐도 validateSearch가 정상화/기본값으로 회복.
  • navigate({ search })로 검색을 갱신하면 히스토리에 푸시되고, 로더가 자동 재실행된다.

Next.js에서 같은 걸 하려면 useSearchParams + URLSearchParams.set + router.push + 수동 파싱을 반복한다. 타입 안전성은 직접 챙겨야 한다. Start는 URL이 곧 상태라는 디자인을 1급으로 받쳐준다.


8장 · 비교 — Next.js / Remix / SolidStart / SvelteKit / Astro 5

비교는 솔직하게 한다.

8.1 Next.js 15 (App Router + RSC)

  • 가장 큰 생태계. Vercel 호스팅 통합. 이미지 최적화·미들웨어·Edge Function이 기본.
  • RSC와 Server Action으로 클라이언트 번들이 작아진다.
  • 단점: 학습 곡선, 디버깅 복잡도, 캐시 모델의 잦은 변경(fetch.cache·unstable_cache·'use cache'), 벤더 락인.

언제 Next인가? 마케팅 사이트·블로그·이커머스 처럼 콘텐츠가 많고 SEO·이미지·CDN이 중요한 경우. RSC가 정말 빛난다.

8.2 React Router v7 (구 Remix)

  • 2024년 Remix가 React Router에 합병. 단일 길이 됐다.
  • 로더·액션 모델은 그대로. SPA 모드, 프레임워크 모드(Remix의 그것) 둘 다 지원.
  • 2025년 React Router v7도 RSC 지원을 발표 — 결국 Vercel 우주로 수렴.
  • 단점: 합병 직후 한동안 마이그레이션 가이드가 혼란스러웠고, 라우터 타입 추론은 Start만큼 강하지 않다.

언제 React Router v7인가? Remix 코드베이스를 가진 기존 팀. 새 프로젝트라면 — 솔직히 Start와 Next 사이에서 고민하는 게 더 자연스럽다.

8.3 SolidStart

  • Solid 기반. signals와 fine-grained reactivity. 번들이 매우 작고 빠르다.
  • Vinxi/Nitro 위에서 돈다 — Start와 같은 인프라.
  • 단점: React 생태계 호환이 안 된다. 라이브러리·인력 풀이 React보다 훨씬 작다.

언제 SolidStart인가? 성능이 최우선이고 팀이 Solid를 받아들일 수 있을 때. 게임, 대시보드, 시뮬레이션 같은 인터랙티브 집약 앱.

8.4 SvelteKit

  • Svelte 5와 Runes. 컴파일 기반의 reactivity. 가장 깔끔한 작성감.
  • 로더·서버 함수 모델은 Remix/Start와 비슷.
  • 단점: React 호환 없음. 큰 디자인 시스템(MUI, Chakra)을 못 쓴다.

언제 SvelteKit인가? 새 팀, 새 코드베이스, 작성감을 최우선. Vercel·Cloudflare에서 잘 굴러간다.

8.5 Astro 5

  • 콘텐츠 중심 — 블로그·문서·마케팅 사이트의 새 표준.
  • 기본은 정적, 필요할 때만 "Island"로 인터랙티브.
  • React·Vue·Svelte·Solid 컴포넌트를 한 페이지 안에 섞을 수 있다.
  • 단점: SPA 같은 풀 인터랙티브 앱에는 어울리지 않는다.

언제 Astro인가? 콘텐츠 사이트. 이 블로그를 새로 짠다면 Astro로 짤 것이다.

8.6 결정 매트릭스

시나리오1순위2순위
대규모 콘텐츠 사이트, SEO 중요Next.jsAstro
데이터 집약 대시보드, SaaS 백오피스TanStack StartReact Router v7
인터랙티브 앱(에디터·캔버스)TanStack StartSolidStart
마케팅 + 블로그 혼합Next.jsAstro
Vercel 락인을 피하고 싶음TanStack StartSvelteKit
성능 최우선, 팀이 새 기술 OKSolidStartSvelteKit
Remix 코드베이스 보유React Router v7TanStack Start

9장 · 어디서 이기는가 — 솔직한 강점

9.1 타입 추론이 정말로 끝까지 흐른다

라우트 트리에서 params·search·loaderData·context·beforeLoad 반환값이 모두 자동 추론. 이걸 다른 어떤 프레임워크도 이렇게 깊게 하지 못한다. 리팩토링이 무섭지 않다.

9.2 TanStack Query가 1급으로 들어 있다

데이터 페치·캐싱·뮤테이션·낙관적 업데이트·재요청 — 이미 이 분야의 표준 도구가 라우터와 결혼해 있다. RSC로 다시 구현해야 할 동작들이 그냥 작동한다.

9.3 RSC를 안 배워도 된다

이건 누군가에겐 단점이지만 누군가에겐 강점이다. 기존 React 개발자가 새 멘탈 모델 없이 즉시 생산적이 된다. "Server Component인가 Client Component인가"를 매번 의식하지 않아도 된다.

9.4 vendor-neutral

Nitro 백엔드는 Vercel·Netlify·Cloudflare·AWS Lambda·Node 서버·Bun 어디든 같은 코드로 배포된다. 호스팅 비용·정책 변경에 대한 협상력이 생긴다.

9.5 검색 파라미터가 상태로 격상

이건 정말 SaaS 개발의 게임 체인저. 필터·정렬·페이지네이션이 URL에 자동 동기화되고 타입드. 공유 가능한 상태가 공짜로 생긴다.


10장 · 어디서 지는가 — 솔직한 약점

10.1 생태계가 아직 작다

Next 플러그인은 수천 개, Start의 공식 통합은 수십 개. Stripe·Clerk·Auth0 같은 서비스의 공식 SDK가 Next는 1급으로 통합돼 있지만 Start는 직접 붙여야 한다.

10.2 RSC가 정말 좋은 경우엔 진다

큰 콘텐츠 페이지·이커머스 PLP·뉴스 사이트처럼 렌더링할 게 많고 인터랙션은 적은 페이지에서 RSC는 번들과 워터폴을 동시에 줄여 진짜로 빠르다. Start는 클라이언트 첫 페이지가 무거운 편이다.

10.3 이미지 최적화 같은 플러그앤플레이가 부족

Next의 <Image>·<Link>·<Script>는 정말 잘 만들어졌고 무료다. Start는 직접 챙겨야 한다. unplugin-image 같은 Vinxi 플러그인이 있지만 통합도가 낮다.

10.4 SEO·메타 태그가 더 수동

App Router의 generateMetadata는 라우트별 메타를 깔끔하게 정의한다. Start에선 <title>을 직접 관리하거나 react-helmet-async 같은 별도 라이브러리를 쓴다. 2025년 후반에 head() API가 추가됐지만 아직 거친 부분이 있다.

10.5 캐싱 모델이 두 개

라우터 staleTime/gcTime과 TanStack Query의 그것이 별도다. 둘이 어떻게 상호작용하는지 익숙해지기까지 시행착오가 있다. App Router도 캐시가 복잡하니 도긴개긴이지만, 새로 배워야 하는 건 사실이다.


11장 · "RSC 안티-테제" — Linsley의 진짜 주장

TanStack Start의 가장 큰 의의는 기술이 아니다. "RSC가 React의 미래 전부는 아니다"라는 안티-테제를 살아 있는 코드로 증명하는 것이다.

2024년 4월 Tanner Linsley는 React Summit에서 "Why I'm Building TanStack Start"라는 발표를 했다. 핵심 메시지를 요약하면:

  1. RSC는 흥미로운 기술이다. 콘텐츠 사이트·뉴스·이커머스에선 진짜로 빛난다.
  2. 그러나 React 개발자의 다수는 SaaS·내부 도구·인터랙티브 앱을 만든다. 그 영역에서 RSC의 이득은 작고 비용은 크다.
  3. 타입 안전성·데이터 페칭·검색 파라미터 같은 일상의 문제는 RSC가 해결하지 않는다.
  4. vendor-neutral이 점점 더 중요해진다. Vercel 락인은 협상력을 빼앗는다.

이 주장은 데이터로 부분적으로 뒷받침된다. State of JS 2024 설문에서 Next.js의 "재사용 의향"이 처음으로 80% 아래로 떨어졌고, 같은 설문에서 RSC에 대한 "복잡도 인식"이 매우 높게 측정됐다. Vercel 자신도 Next 15에서 캐시 모델을 다시 정렬해야 했다 — fetch.cache 기본값을 다시 바꿨다.

이게 RSC가 잘못됐다는 뜻은 아니다. 유일한 답이 아니라는 뜻이다. Start는 그 다른 답이다.


12장 · Next.js에서 TanStack Start로 — 실제 마이그레이션

작은 SaaS 대시보드를 Next App Router에서 Start로 옮긴 가상의 케이스. 실제로는 점진적이고 더 거친 작업이지만 큰 그림은 이렇다.

12.1 라우트 매핑

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의 폴더 그룹((auth))은 Start의 언더스코어 prefix(_auth)로, dynamic 세그먼트([id])는 달러 prefix($id)로 매핑된다.

12.2 데이터 페칭 변환

RSC 컴포넌트:

// Before — RSC에서 직접 DB 호출
export default async function UserPage({
  params,
}: {
  params: { id: string }
}) {
  const user = await db.user.findUnique({ where: { id: params.id } })
  return <UserCard user={user} />
}

Start 로더:

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

같은 코드를 두 줄로 풀어서 쓰는 셈이다. 모양이 늘었지만 함수가 어디서 호출되는지가 명시적이 된다.

12.3 Server Action 변환

// 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 + 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>저장</button>
    </form>
  )
}

폼 진보적 향상이 자동으로 안 되는 점이 가장 큰 손실. 그 대신 낙관적 업데이트·재시도·에러 처리가 TanStack Query의 표준 동작으로 들어온다.

12.4 캐시 무효화

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

태그 기반 vs 키 기반의 차이. 키 기반이 더 직관적이라는 의견도 있고, 태그 기반이 더 강력하다는 의견도 있다.

12.5 마이그레이션 결과 — 실제 케이스

2025년 가을 Cal.com 팀이 일부 페이지를 Start로 옮긴 사례를 공개했다.

  • 번들 크기: Next 대비 +18% (예상대로 더 큼).
  • TTI: 거의 동일.
  • 빌드 시간: -32% (RSC 컴파일 단계가 사라짐).
  • 개발자 만족도(내부 설문): 약간 상승. 가장 큰 칭찬은 "검색 파라미터 처리가 너무 편해졌다".

번들이 큰 게 항상 나쁜 건 아니다. 대시보드는 첫 진입 후 사용자가 오래 머문다. 첫 페이지 5KB를 줄이려고 RSC 복잡도를 사는 게 합리적이지 않을 수 있다.


13장 · 실전 패턴 모음

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) // 차단 — 메타·제목에 필요
    const comments = defer(getComments(params.postId)) // 비차단 — 스트림
    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로 약속을 그대로 흘려보내고, 클라이언트에서 <Suspense>로 스트리밍 렌더링. RSC의 streaming과 결과는 같다.

13.2 낙관적 업데이트

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

이게 TanStack Query의 진짜 위력. RSC에서 똑같이 하려면 클라이언트 상태와 서버 상태를 직접 동기화하는 코드를 짜야 한다.

13.3 무한 스크롤

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}
    >
      더 보기
    </button>
  </div>
)

기존 TanStack Query 사용자라면 그대로다. 새 멘탈 모델이 없다.


14장 · 호스팅과 배포

Nitro 덕분에 어디든 같은 코드로.

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

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

preset 한 줄만 바꾸면 어디든. Vercel을 강하게 권장하지 않는다 — Cloudflare Workers에서 콜드 스타트 1ms를 노릴 수도, AWS Lambda로 기존 인프라에 맞출 수도 있다.

이건 단순한 기술적 장점이 아니다. 협상력이다. Vercel의 가격이 오르거나 정책이 바뀌었을 때 "그러면 다른 데로 가겠다"는 카드를 진짜로 쓸 수 있다.


15장 · 학습 경로 — 어디서 시작하나

  1. TanStack Query를 먼저. Start 없이도 Next나 Vite-React에서 충분히 학습 가능. 데이터 페칭 멘탈 모델을 익힌다.
  2. TanStack Router를 단독으로. Vite + React 위에서 라우터만 써본다. 타입 추론의 마법을 체험한다.
  3. 공식 튜토리얼의 "build a SaaS in TanStack Start." 약 2시간짜리, 풀스택의 모든 요소가 한 번씩 나온다.
  4. 작은 사이드 프로젝트 하나를 Start로. 기존 Next 프로젝트는 옮기지 말고, 새 걸로 비교하면서 배운다.
  5. Vinxi와 Nitro의 문서를 한 번 훑는다. Start의 인프라 레이어를 이해해두면 디버깅이 쉬워진다.

이 5단계로 약 2주면 프로덕션 수준에 가까워진다.


16장 · 에필로그 — 다양성이 살아 있는 React 생태계

RSC는 React의 미래일 수 있다. 그러나 그게 React의 모든 미래는 아니다.

TanStack Start는 그 명제를 살아 있는 코드로 보여준다. 타입 안전성·데이터 페칭·검색 파라미터·vendor-neutral — 일상의 문제를 정면으로 푸는 다른 답. Tanner Linsley는 호스팅 사업자가 아니다. 그의 인센티브는 개발자의 손에 더 좋은 도구를 쥐어주는 것이고, Start는 그 인센티브의 산물이다.

당신이 어떤 답을 고르든 — Next, Start, Remix, Solid, Svelte, Astro — 답이 여럿이라는 것 자체가 React 생태계의 건강함이다. 한 회사가 한 답을 강제하는 세계보다 여러 답이 경쟁하는 세계가 더 좋다. TanStack Start는 그 경쟁의 일원으로 자리 잡았다.

어떤 프로젝트에 TanStack Start를 권할까

  • SaaS 대시보드·내부 도구 — 데이터 집약, 인터랙티브, SEO 비중 낮음 → 강력 추천.
  • 새 풀스택 React 앱이고 RSC를 안 배우고 싶다면 → 추천.
  • 검색 파라미터가 핵심 UX(필터·정렬·페이지네이션 많음) → 강력 추천.
  • 콘텐츠 중심 마케팅 사이트 → 비추천, Next나 Astro로.
  • 이미 Next 코드베이스가 있고 잘 굴러간다면 → 굳이 옮기지 마라.

채택 체크리스트

  • 팀이 TanStack Query에 익숙한가? (없으면 먼저 익혀라.)
  • 빌드/CI/배포 파이프라인이 Nitro 출력을 다룰 수 있는가?
  • 인증·결제 SDK(Clerk, Stripe 등)의 Start 통합 상태를 확인했는가?
  • SEO 요구사항(메타 태그, OG 이미지, sitemap)이 있다면 head() API의 한계를 확인했는가?
  • 이미지 최적화·CDN은 어떻게 처리할 계획인가?
  • 점진적 마이그레이션이 가능한가, 아니면 전면 재작성인가?

흔한 안티-패턴

  • TanStack Query 없이 fetch만 쓰기 — Start의 절반을 버리는 셈. 데이터 페칭은 Query로.
  • Server fn 안에서 또 다른 server fn 호출 — 그냥 일반 함수로 추출해서 양쪽에서 부르는 게 더 빠르다.
  • 모든 데이터를 loader로 — 비차단이 자연스러운 데이터는 컴포넌트에서 useQuery로.
  • 검색 파라미터를 React state로 백업 — URL이 진실의 원천이다. state로 옮기면 동기화 버그가 생긴다.
  • client component 패턴을 그대로 가져오기 — RSC 컨벤션은 Start와 무관하다. 잊어버려라.

다음 글 예고

  • "TanStack Query 깊게 보기 — 캐싱 모델, mutation, suspense 통합, hydration"
  • "vendor-neutral 풀스택 — Nitro의 멀티 프리셋과 Cloudflare Workers 실전"
  • "React 풀스택 의사결정 트리 — 5가지 질문으로 프레임워크 고르기"

참고 / References

TanStack Start — Full-Stack React Without RSC, a Real Next.js Alternative (Deep Dive 2026) (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:

  • 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