Privacy Policy© 2026 DEV BAK - TECH BLOG. All rights reserved.
DEV BAK - TECH BLOG
frontend

From Direct DB Calls Without RSC to Streaming — Where TanStack Start and Next.js Diverge on Full-Stack Boundaries

When Next.js 13 App Router launched, I'll be honest — it threw me off. The anxiety of toggling use client on and off wondering "is it just me?", the runtime errors from trying to use useState inside a Server Component. If you've spent any time with React and Next.js, you've probably been there. Around that time, TanStack Start was quietly walking in a different direction — carrying the premise that "you can go full-stack without RSC."

With the official v1.0 release in November 2025, TanStack Start moved from "interesting experiment" to a viable production choice. It now has a stable API, documentation, and Nitro-based support for a variety of deployment targets. In this article, we'll walk through three layers — createServerFn, Selective SSR, and streaming — to examine how much ground RSC-free covers, and where Next.js remains the pragmatic choice. If you build data-centric SaaS products, admin panels, or B2B apps, you'll come away with especially practical decision criteria. By the end, you'll be able to make a reasoned call on whether TanStack Start belongs in your next project.

Fair warning upfront — this is written for people who've used React and Next.js and have had at least one "wait, is this right?" moment with RSC or Server Actions. This article isn't an argument to always use TanStack Start. There are clear cases where RSC is the right call, and we'll look at those boundaries too.


Core Concepts

TanStack Start has three core layers — the Just React philosophy (client-driven composition), Selective SSR (per-route rendering strategy), and createServerFn (server-client RPC). These aren't independent features. The Just React philosophy is the foundational principle, Selective SSR determines the rendering strategy at the route level, and createServerFn safely isolates server code within each layer. Let's look at each one.

What the "Just React" Philosophy Means

TanStack Start's core philosophy is "just React". No RSC, no compiler magic, no use client directives — just React components written the way we've always written them. And yet you get all the full-stack features: server rendering, data fetching, API routes.

Under the hood, it runs on top of Vite and Nitro. TanStack Router handles the routing layer, and Nitro handles the server runtime. Vite owns the build pipeline, so HMR is fast and you can use the existing Vite plugin ecosystem as-is.

Nitro: A server runtime that emerged from the Nuxt ecosystem. Built on the H3 HTTP framework, it provides an adapter layer that lets you deploy the same code to Node.js, Cloudflare Workers, Vercel, AWS Lambda, and more. Nitro is exactly why TanStack Start isn't tied to any specific deployment environment.

Selective SSR — Choosing a Rendering Strategy Per Route

One of RSC's strengths is "per-component server/client separation," and TanStack Start answers this at the route level. A single ssr option in the route definition lets you pick one of three strategies.

typescript
// app/routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
  // Full server rendering — ideal for pages where SEO matters
  ssr: true,
 
  // Server fetches data only, component renders on the client
  // ssr: 'data-only',
 
  // Pure client-side SPA — for internal admin pages, etc.
  // ssr: false,
 
  loader: async () => fetchDashboardData(),
  component: DashboardPage,
})

When I first saw this option I wondered "what exactly does this replace from RSC?", but after using it, the cases it covers turned out to be broader than I expected. For something like a SaaS admin, most routes work fine with ssr: 'data-only'. The pattern of fetching and validating data on the server while rendering on the client covers a substantial portion of what RSC's "server-side data fetching" addresses.

The ssr: 'data-only' pattern: The loader function runs on the server, but component rendering happens on the client. DB access and environment variable usage stay on the server; interactivity stays on the client — the roles are cleanly separated. The TanStack Start official Selective SSR guide covers this pattern in depth.

createServerFn — The RPC Replacement for RSC's Async Server Components

This is the most important concept in TanStack Start. It's an RPC mechanism that lets you call server-only functions from the client in a type-safe way — at build time, the client bundle replaces them with RPC stubs. Your actual server code never gets exposed to the browser.

typescript
// app/functions/user.ts
// Code in this file runs on the server only
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from '~/server/db'  // Works the same way with Prisma or Drizzle
 
const getUser = createServerFn({ method: 'GET' })
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data }) => {
    return db.user.findUnique({ where: { id: data.id } })
  })
 
const updateUserProfile = createServerFn({ method: 'POST' })
  .validator(z.object({
    id: z.string(),
    name: z.string().min(1),
    email: z.string().email(),
  }))
  .handler(async ({ data }) => {
    return db.user.update({
      where: { id: data.id },
      data: { name: data.name, email: data.email },
    })
  })
 
export { getUser, updateUserProfile }
typescript
// app/routes/profile.$userId.tsx
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { getUser, updateUserProfile } from '~/functions/user'
 
export const Route = createFileRoute('/profile/$userId')({
  loader: async ({ params }) => {
    // Calling a server function from a loader — runs directly server-side during SSR
    return getUser({ data: { id: params.userId } })
  },
  component: ProfilePage,
})
 
function ProfilePage() {
  const user = Route.useLoaderData()
  const [error, setError] = useState<string | null>(null)
 
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    try {
      await updateUserProfile({
        data: {
          id: user.id,
          name: formData.get('name') as string,
          email: formData.get('email') as string,
        },
      })
    } catch (err) {
      // Errors thrown from createServerFn are forwarded to the client as-is
      setError(err instanceof Error ? err.message : 'An error occurred')
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="error-message">{error}</p>}
      <input name="name" defaultValue={user.name} />
      <input name="email" defaultValue={user.email} />
      <button type="submit">Save</button>
    </form>
  )
}

What RSC handles with async Server Components is handled here with explicit function calls. Some people say "explicit is better," others say "RSC is more intuitive." I'm in the first camp, for a simple reason — when something breaks, the error stack tells you exactly where. If you've ever been stuck tracing an error that exploded mid-render in RSC, you'll feel immediately how much easier this is.

Streaming SSR and Suspense — And Where Does the Data Come From?

Progressive page loading is possible without RSC. Combining React Suspense with TanStack Query lets you stream server-rendered output in chunks. In this section, we'll look at the part I was most curious about when I first saw this — how components being streamed actually fetch their data internally.

typescript
// app/functions/analytics.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from '~/server/db'
 
export const getRevenueData = createServerFn({ method: 'GET' })
  .handler(async () => {
    // Heavy DB aggregation queries also run server-side only
    return db.order.groupBy({
      by: ['createdAt'],
      _sum: { amount: true },
      orderBy: { createdAt: 'asc' },
    })
  })
 
export const getUserActivity = createServerFn({ method: 'GET' })
  .validator(z.object({ days: z.number().default(30) }))
  .handler(async ({ data }) => {
    const since = new Date(Date.now() - data.days * 86400 * 1000)
    return db.userEvent.findMany({
      where: { createdAt: { gte: since } },
    })
  })
typescript
// app/routes/analytics.tsx
import { Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { getRevenueData, getUserActivity } from '~/functions/analytics'
 
export const Route = createFileRoute('/analytics')({
  ssr: true,
  component: AnalyticsPage,
})
 
function RevenueChart() {
  // useSuspenseQuery — this component must be inside a Suspense boundary to work
  const { data } = useSuspenseQuery({
    queryKey: ['revenue'],
    queryFn: () => getRevenueData(),
  })
  return <LineChart data={data} />
}
 
function UserActivityTable() {
  const { data } = useSuspenseQuery({
    queryKey: ['user-activity'],
    queryFn: () => getUserActivity({ data: { days: 30 } }),
  })
  return <DataTable rows={data} />
}
 
function AnalyticsPage() {
  return (
    <div className="grid gap-6">
      {/* Layout renders immediately */}
      <PageHeader title="Analytics" />
 
      {/* Each Suspense boundary streams independently */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
 
      <Suspense fallback={<TableSkeleton />}>
        <UserActivityTable />
      </Suspense>
    </div>
  )
}

When I first saw this pattern I thought "how is this different from Next.js RSC streaming?", but after using it one difference became clear. The connection between Suspense boundaries and data fetching is entirely explicit. In RSC there are hidden parts to where Suspense gets triggered, but here useSuspenseQuery makes it immediately readable in the code which component is waiting for which data. It felt like I had clearer control in my own hands.


Practical Application

Example 1: SaaS Admin Dashboard — ssr: 'data-only' + Server Function Pattern

The most common real-world scenario. Admin dashboards don't need SEO, but initial load speed and data security matter. ssr: 'data-only' is the perfect fit.

typescript
// app/routes/admin/orders.tsx
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from '~/server/db'
import { requireAdmin } from '~/server/auth'
 
// Server function — DB access and permission checks all handled server-side
const getOrders = createServerFn({ method: 'GET' })
  .validator(z.object({
    page: z.number().default(1),
    status: z.enum(['pending', 'shipped', 'delivered']).optional(),
  }))
  .handler(async ({ data }) => {
    // Auth check logic is never exposed to the client
    await requireAdmin()
 
    return db.order.findMany({
      where: { status: data.status },
      skip: (data.page - 1) * 20,
      take: 20,
      include: { user: true, items: true },
    })
  })
 
export const Route = createFileRoute('/admin/orders')({
  // Loader on the server, rendering on the client
  ssr: 'data-only',
 
  // Type-safely validates URL search params (a TanStack Router-exclusive feature)
  validateSearch: z.object({
    page: z.number().catch(1),
    status: z.enum(['pending', 'shipped', 'delivered']).optional(),
  }),
 
  loader: async ({ location }) => {
    // location.search is already typed via the validateSearch schema
    const { page, status } = location.search
    return getOrders({ data: { page, status } })
  },
 
  component: OrdersPage,
})
 
function OrdersPage() {
  const orders = Route.useLoaderData()
  const { page, status } = Route.useSearch()
 
  return (
    <div>
      <OrderFilters currentStatus={status} />
      <OrderTable orders={orders} />
      <Pagination currentPage={page} />
    </div>
  )
}
Element Role
ssr: 'data-only' Loader on server, rendering on client — preserves interactive UI
validateSearch Guarantees type safety for URL search params (TanStack Router-exclusive feature)
requireAdmin() Runs only inside the server function — never exposed in the client bundle
Route.useLoaderData() Loader return type is automatically inferred — no separate type declaration needed

Example 2: Streaming Dashboard — Separating Heavy Charts with Suspense

For analytics pages that require multiple DB aggregation queries, waiting for all data before rendering delays the initial paint. The streaming pattern lets you show the layout first and fill in heavy components later. You can extend the server function pattern defined in Example 1 directly.

typescript
// app/routes/analytics.tsx
import { Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { getRevenueData, getUserActivity } from '~/functions/analytics'
 
export const Route = createFileRoute('/analytics')({
  ssr: true,
  component: AnalyticsPage,
})
 
function RevenueChart() {
  const { data } = useSuspenseQuery({
    queryKey: ['revenue'],
    queryFn: () => getRevenueData(),
  })
  return <LineChart data={data} />
}
 
function UserActivityTable() {
  const { data } = useSuspenseQuery({
    queryKey: ['user-activity'],
    queryFn: () => getUserActivity({ data: { days: 30 } }),
  })
  return <DataTable rows={data} />
}
 
function AnalyticsPage() {
  return (
    <div className="grid gap-6">
      <PageHeader title="Analytics" />
 
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
 
      <Suspense fallback={<TableSkeleton />}>
        <UserActivityTable />
      </Suspense>
    </div>
  )
}

The useSuspenseQuery + createServerFn combination makes it immediately readable in code which component is waiting for which data — something I found more convenient than I expected. The developer experience difference compared to Next.js RSC streaming isn't huge, but personally, debugging was significantly easier.

Example 3: From React Router to TanStack Start — How Much Does Migration Cost?

At ReactJS Day 2025, a talk covered migrating a large codebase from React Router to TanStack Start, and the key takeaway was "you barely need to touch existing components." You only need to change the route definition style.

typescript
// Previous React Router v6
// routes/dashboard.tsx
import { useLoaderData } from 'react-router-dom'
 
export async function loader() {
  return fetch('/api/dashboard').then(r => r.json())
}
 
export default function Dashboard() {
  const data = useLoaderData()
  return <DashboardUI data={data} />
}
typescript
// Migrated to TanStack Start
// app/routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getDashboardData } from '~/functions/dashboard'
 
export const Route = createFileRoute('/dashboard')({
  ssr: 'data-only',
  loader: async () => getDashboardData(),
  component: Dashboard,
})
 
function Dashboard() {
  // Route.useLoaderData instead of useLoaderData — return type is automatically inferred
  const data = Route.useLoaderData()
  return <DashboardUI data={data} />
}

The DashboardUI component itself doesn't need to be touched. You only replace the route wrapper and data-fetching layer. Being able to carry existing component assets over unchanged significantly lowers migration cost.


Pros and Cons Analysis

Advantages

Item Details
Simpler mental model No use client / use server boundary management. Server code lives only inside createServerFn; everything else is client
Full type safety The type system covers the server-client boundary. Route params, search params, and loader return types are all automatically inferred
Vite build speed Dev server cold starts and HMR stay fast even as the project grows. A Platformatic benchmark reported up to 4x faster results compared to Next.js RSC builds
Smaller JS bundle The same benchmark reported roughly 40% less JS bundle compared to Next.js
Transparent behavior Framework magic layers are minimal. When errors occur, stack traces are predictable
Deployment flexibility Nitro-based deployment to Node.js, Cloudflare Workers, Vercel, Netlify, AWS Lambda, and more from the same code

Honestly, the one I felt most in practice was "transparent behavior." If you've ever stared blankly at an RSC error not knowing where it came from, you'll quickly feel how readable a createServerFn-based error stack is.

Disadvantages and Caveats

Item Details Mitigation
Ecosystem size Fewer tutorials, third-party integrations, and starter templates compared to Next.js Official docs quality is high — start with official examples
AI coding tool support Copilot, Cursor, etc. are trained far more heavily on Next.js patterns Providing TanStack official docs as context covers a lot of this
No component-level server rendering Unlike RSC, per-component server/client separation isn't possible — route is the minimum unit ssr: 'data-only' + TanStack Query combination handles much of this
No built-in image/font optimization No <Image> or automatic font optimization like Next.js provides Third-party image components like @unpic/react can fill the gap
Lack of enterprise references Fewer production cases from large organizations compared to Next.js Cases have been accumulating quickly since v1.0
Overkill for static sites An excessive choice for purely static content like marketing pages Astro is more appropriate for static-content-heavy sites

Among these, the "AI coding tool support" item turned out to be less of a problem than expected. Drop a TanStack docs link as context and Cursor follows along fairly well. The "ecosystem size" side — specifically the lack of auth integration starters — actually cost more time during initial setup. That part is still honestly lacking.

TanStack Query (v5): A library for caching and syncing server function results on the client. Combined with the ssr: 'data-only' pattern, it can cover a substantial portion of RSC's data-fetching role at the client cache layer.

The Most Common Mistakes in Practice

  1. Defining createServerFn inside a component file — It's tempting to define it right at the top of the component file for convenience, but when I looked at the build output, server code was mixed into the client bundle. Build-time bundle separation may not work as intended. Always isolate server functions into separate files (~/functions/).

  2. Setting all routes to ssr: true — After adding full SSR to admin pages that don't need SEO, I checked DevTools and found unnecessary server render requests. Switching them to ssr: 'data-only' was the first step toward reducing server load. Pick the strategy that matches the nature of each route.

  3. Using search params without validateSearch — One of TanStack Router's most powerful features is type-safe search params, but without defining a validateSearch schema, location.search becomes type any. This leads to bugs that fail silently at runtime — especially when a page param is expected as a number but arrives as a string.


Closing Thoughts

TanStack Start — without RSC — covers full-stack requirements at the level of SaaS dashboards, admin panels, and B2B apps using just three layers: createServerFn, Selective SSR, and streaming.

If you have cases where RSC is genuinely necessary — per-component server/client separation, Vercel AI SDK's streamUI, RSC-exclusive APIs like use(cache) — Next.js is the pragmatic choice. But for most other scenarios in data-centric apps, TanStack Start's simple, predictable structure actually improves productivity. I started out wondering "won't something be missing without RSC?", and after using it, the one thing that changed my mind was this — when something breaks, you can see exactly where it happened. That turned out to make a bigger difference in development speed than I expected.

Three steps you can start with right now:

  1. Try creating a project — pnpm create tanstack --template react-start-basic sets up a base project with file-based routing and basic SSR already configured.
  2. Build one createServerFn yourself — No DB required. Create a simple function that reads process.env or accesses the file system, then check the DevTools network tab and watch that code disappear from the client bundle. The behavior becomes intuitively clear.
  3. Compare two SSR strategies in the same app — Set one route to ssr: true and another to ssr: 'data-only', then compare the network tab and page source. The difference between each mode becomes concrete, and you'll develop a sense for which strategy fits which page.

References

  • TanStack Start Overview | TanStack Official Docs
  • Selective SSR | TanStack Official Docs
  • Server Functions | TanStack Official Docs
  • React Server Components Your Way | TanStack Blog
  • Why TanStack Start is Ditching Adapters | TanStack Blog
  • TanStack Start vs Next.js Comparison | TanStack Official Docs
  • TanStack Start v1 Release | InfoQ
  • TanStack Start Overview | LogRocket
  • Selective SSR in TanStack Start | LogRocket
  • TanStack Start vs Next.js | LogRocket
  • React SSR Framework Benchmark | Platformatic
  • React Server Components in TanStack | Frontend Masters
  • TanStack Start Deployment Guide | Cloudflare Workers
  • TanStack Start vs Next.js 2026 | Alex Cloudstar
#TanStackStart#NextJS#RSC#SSR#createServerFn#TanStackQuery#React#Vite#TypeScript#Nitro
Share

Table of Contents

Core ConceptsWhat the "Just React" Philosophy MeansSelective SSR — Choosing a Rendering Strategy Per RoutecreateServerFnStreaming SSR and Suspense — And Where Does the Data Come From?Practical ApplicationExample 1: SaaS Admin Dashboard —Example 2: Streaming Dashboard — Separating Heavy Charts with SuspenseExample 3: From React Router to TanStack Start — How Much Does Migration Cost?Pros and Cons AnalysisAdvantagesDisadvantages and CaveatsThe Most Common Mistakes in PracticeClosing ThoughtsReferences

Recommended Posts

Deleting 400 Lines of useMemo with React Compiler 1.0 Cut TBT by 15%
frontend

Deleting 400 Lines of useMemo with React Compiler 1.0 Cut TBT by 15%

What you need to know before handing memoization over to the compiler Honestly, when I first learned , I overused it myself. Thinking "shouldn't everything e...

June 26, 202618 min read
ESLint vs Biome vs Oxlint — Speed, Ecosystem, and Migration Cost Comparison for Frontend Linting Tools in 2026
frontend

ESLint vs Biome vs Oxlint — Speed, Ecosystem, and Migration Cost Comparison for Frontend Linting Tools in 2026

A linter is a tool that catches potential bugs, bad patterns, and style inconsistencies before your code runs. If you've ever had a mistake slip through to prod...

June 26, 202622 min read
Vite 8 + Rolldown: How a Dual-Bundler Integration Cut Production Build Times by 87%
frontend

Vite 8 + Rolldown: How a Dual-Bundler Integration Cut Production Build Times by 87%

(Vite 8 Rolldown migration | build speed) When I first heard that a 46-second build had dropped to 6 seconds, I was honestly skeptical. Benchmark numbers alw...

June 26, 202617 min read
Removing Popper.js with CSS Anchor Positioning — Floating UI Handled Directly by the Browser
frontend

Removing Popper.js with CSS Anchor Positioning — Floating UI Handled Directly by the Browser

Anyone who has done frontend work eventually thinks to themselves: "Why is attaching a dropdown below a button this complicated?" When I first built my team's d...

June 26, 202621 min read
15 Frontend Accessibility Checklist Items After EAA Takes Effect (European Accessibility Act WCAG 2.1 AA Standard)
frontend

15 Frontend Accessibility Checklist Items After EAA Takes Effect (European Accessibility Act WCAG 2.1 AA Standard)

On June 28, 2025, I stopped while reading a message that came through on Slack: "EAA has taken effect — is our service okay?" At first I thought, "It's an EU la...

June 26, 202626 min read
Implementing Cloudflare Workers for Edge MFE Orchestration to Reduce TTFB
frontend

Implementing Cloudflare Workers for Edge MFE Orchestration to Reduce TTFB

Honestly, when I first heard about micro-frontends (MFE), my immediate thought was, "Independent deployments per team? Sounds great — but doesn't that mean the ...

June 23, 202623 min read