React
|stacknotice.com
14 min left|
0%
|2,800 words
React

TanStack Start (2026): Full-Stack React Without Next.js

TanStack Start is a production-ready full-stack React framework built on TanStack Router, Vinxi, and Nitro. File-based routing, server functions, streaming SSR — no Next.js required.

C
Carlos Oliva
Software Developer
August 20, 202614 min read
Share:
TanStack Start (2026): Full-Stack React Without Next.js

Next.js has dominated the full-stack React space for years. TanStack Start is the first serious alternative built from scratch for the current era — type-safe routing, server functions with real TypeScript inference, and a deployment model that runs anywhere Nitro runs.

TanStack Start is not a clone of Next.js. It's built on three pieces you may already know: TanStack Router for client-side routing, Vinxi as the application bundler, and Nitro as the server engine. Each piece is replaceable; together they produce something that competes directly with Next.js App Router and Remix.

What TanStack Start Gives You

Before diving into code, the features that actually matter:

  • Type-safe routing — route params are typed end-to-end, no string casting
  • Server functions — call backend code from the frontend with full TypeScript inference, no API routes required
  • File-based routing — same mental model as Next.js, built on TanStack Router conventions
  • Streaming SSR — built-in React Suspense streaming, no configuration needed
  • Deploy anywhere — Nitro adapters cover Vercel, Cloudflare Workers, AWS Lambda, Node.js, Bun
  • No vendor lock-in — unlike Next.js, TanStack Start has no proprietary primitives

Project Setup

# Create a new project (official template)
npx create-tsrouter-app@latest my-app --template start-basic
cd my-app
npm install
npm run dev

Project structure after creation:

my-app/
├── app/
│   ├── routes/
│   │   ├── __root.tsx          # Root layout — always rendered
│   │   ├── index.tsx           # /
│   │   ├── about.tsx           # /about
│   │   └── posts/
│   │       ├── index.tsx       # /posts
│   │       └── $postId.tsx     # /posts/:postId — typed param
│   ├── client.tsx              # Client entry
│   ├── router.tsx              # Router configuration
│   └── ssr.tsx                 # Server entry
├── app.config.ts               # Vinxi config — adapters, plugins
└── package.json

File-Based Routing

TanStack Start inherits TanStack Router's routing conventions. Routes are files, params are $-prefixed, layouts are nested.

// app/routes/__root.tsx — rendered for every route
import { createRootRoute, Outlet, Link } from '@tanstack/react-router'
 
export const Route = createRootRoute({
  component: () => (
    <div>
      <nav>
        <Link to="/" activeOptions={{ exact: true }}>Home</Link>
        <Link to="/posts">Posts</Link>
      </nav>
      <Outlet />
    </div>
  )
})
// app/routes/posts/$postId.tsx — dynamic segment, fully typed
import { createFileRoute } from '@tanstack/react-router'
import { fetchPost } from '../serverFunctions/posts'
 
export const Route = createFileRoute('/posts/$postId')({
  // loader runs on server for SSR, on client for navigation
  loader: ({ params }) => fetchPost(params.postId),  // params.postId is string, typed
  component: PostPage,
})
 
function PostPage() {
  const post = Route.useLoaderData()  // typed from loader return
  const { postId } = Route.useParams()  // typed, no casting
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

The key difference from Next.js: Route.useLoaderData() is typed from the loader's return type automatically. No async params workarounds, no manual typing of dynamic segments.

Server Functions

Server functions are TanStack Start's equivalent of Next.js Server Actions. They're regular TypeScript functions that run on the server but can be called from the client — with full type inference on both sides.

// app/serverFunctions/posts.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from '../lib/db'
 
// A server function — runs on server, callable from client
export const fetchPost = createServerFn({ method: 'GET' })
  .validator(z.object({ postId: z.string() }))
  .handler(async ({ data }) => {
    const post = await db.query.posts.findFirst({
      where: (posts, { eq }) => eq(posts.id, data.postId),
      with: { author: true, tags: true }
    })
    if (!post) throw new Error('Post not found')
    return post
  })
 
// Mutation server function — POST method
export const createPost = createServerFn({ method: 'POST' })
  .validator(z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(1),
    tags: z.array(z.string()).optional()
  }))
  .handler(async ({ data, context }) => {
    // context.user is available if you set up middleware
    const post = await db.insert(posts).values({
      ...data,
      authorId: context.user.id,
      createdAt: new Date()
    }).returning()
    return post[0]
  })

Calling from a route component:

// app/routes/posts/new.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createPost } from '../serverFunctions/posts'
import { useServerFn } from '@tanstack/start'
 
export const Route = createFileRoute('/posts/new')({
  component: NewPostPage,
})
 
function NewPostPage() {
  const navigate = useNavigate()
  const create = useServerFn(createPost)
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    const post = await create({
      data: {
        title: data.get('title') as string,
        content: data.get('content') as string,
      }
    })
    navigate({ to: '/posts/$postId', params: { postId: post.id } })
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit">Publish</button>
    </form>
  )
}

Compared to Next.js Server Actions, TanStack Start server functions have better TypeScript inference out of the box — you don't need to manually type the action's return value in the client.

Loaders: Data Fetching That Works on Both Sides

Route loaders run on the server during SSR and on the client during client-side navigation. This is the same model as Remix — one data-fetching primitive that adapts to context.

// app/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fetchPosts } from '../serverFunctions/posts'
 
export const Route = createFileRoute('/posts/')({
  // loader data is available synchronously in the component
  loader: async () => {
    const posts = await fetchPosts({ data: { limit: 20 } })
    return { posts }
  },
  // pendingComponent shown during client-side navigation
  pendingComponent: () => <div>Loading...</div>,
  // errorComponent shown when loader throws
  errorComponent: ({ error }) => <div>Error: {error.message}</div>,
  component: PostsList,
})
 
function PostsList() {
  const { posts } = Route.useLoaderData()
 
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <Link to="/posts/$postId" params={{ postId: post.id }}>
            {post.title}
          </Link>
        </li>
      ))}
    </ul>
  )
}

Parallel loading across nested routes — another Remix idea TanStack Start inherits:

// Parent and child loaders run in parallel automatically
// /posts loads the post list
// /posts/$postId loads the specific post
// No waterfalls

Middleware

TanStack Start has server middleware that runs before route handlers and server functions. This is where auth, logging, and request context live.

// app/middleware/auth.ts
import { createMiddleware } from '@tanstack/start'
import { getSession } from '../lib/auth'
 
export const authMiddleware = createMiddleware().server(async ({ next, context }) => {
  const session = await getSession()
  return next({
    context: {
      ...context,
      user: session?.user ?? null,
      isAuthenticated: !!session?.user,
    }
  })
})
 
// Middleware for specific server functions
export const requireAuth = createMiddleware()
  .middleware([authMiddleware])
  .server(async ({ next, context }) => {
    if (!context.isAuthenticated) {
      throw new Error('Unauthorized')
    }
    return next()
  })

Using middleware in server functions:

// app/serverFunctions/posts.ts
export const deletePost = createServerFn({ method: 'POST' })
  .middleware([requireAuth])  // user is guaranteed to be in context
  .validator(z.object({ postId: z.string() }))
  .handler(async ({ data, context }) => {
    // context.user is typed and non-null here
    await db.delete(posts)
      .where(
        and(
          eq(posts.id, data.postId),
          eq(posts.authorId, context.user.id)
        )
      )
  })

Streaming SSR

TanStack Start supports React Suspense streaming. Wrap async data in <Suspense> and it streams progressively to the client:

// app/routes/dashboard.tsx
import { createFileRoute, defer } from '@tanstack/react-router'
import { Suspense } from 'react'
import { fetchDashboardStats, fetchRecentActivity } from '../serverFunctions/dashboard'
 
export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    // fetchDashboardStats is awaited — blocks initial render
    // fetchRecentActivity is deferred — streams later
    const [stats, activityPromise] = await Promise.all([
      fetchDashboardStats({ data: {} }),
      defer(fetchRecentActivity({ data: {} }))
    ])
    return { stats, activityPromise }
  },
  component: Dashboard,
})
 
function Dashboard() {
  const { stats, activityPromise } = Route.useLoaderData()
 
  return (
    <div>
      {/* Renders immediately — stats were awaited */}
      <StatsPanel stats={stats} />
 
      {/* Streams in when ready */}
      <Suspense fallback={<ActivitySkeleton />}>
        <Await promise={activityPromise}>
          {(activity) => <ActivityFeed activity={activity} />}
        </Await>
      </Suspense>
    </div>
  )
}

Search Params — Typed, Always

TanStack Router's typed search params carry through to TanStack Start. URL state is fully typed without manual parsing:

// app/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
 
const searchSchema = z.object({
  page: z.number().default(1),
  tag: z.string().optional(),
  sort: z.enum(['newest', 'popular']).default('newest'),
})
 
export const Route = createFileRoute('/posts/')({
  validateSearch: searchSchema,
  loader: async ({ search }) => {
    // search.page, search.tag, search.sort are all typed
    return fetchPosts({ data: { ...search } })
  },
  component: PostsList,
})
 
function PostsList() {
  const search = Route.useSearch()  // typed from schema
  const navigate = useNavigate()
 
  function setPage(page: number) {
    navigate({
      search: (prev) => ({ ...prev, page })  // typed update
    })
  }
 
  // ...
}

Contrast with Next.js useSearchParams() which returns URLSearchParams — strings only, no validation, manual casting everywhere.

Deployment

TanStack Start uses Nitro as its server engine, which means the same Nitro adapter system:

// app.config.ts — Vinxi configuration
import { defineConfig } from '@tanstack/start/config'
 
export default defineConfig({
  server: {
    // Nitro preset — change this to target different platforms
    preset: 'vercel',  // or 'cloudflare-pages', 'aws-lambda', 'bun', 'node-server'
  },
  vite: {
    plugins: [] // Vite plugins work here
  }
})

Deploying to different platforms is a config change, not a rewrite. This is a genuine advantage over Next.js, where switching from Vercel to a self-hosted Node.js server requires non-trivial changes.

# Build for production
npm run build
 
# Vercel — automatic when you push (if preset: 'vercel')
# Cloudflare Pages — push + Pages project configured
# Node.js server
node .output/server/index.mjs

Integrating with tRPC

TanStack Start's server functions handle most use cases, but if you're already using tRPC in a Next.js project and want to migrate, tRPC works as an HTTP handler within TanStack Start:

// app/routes/api/trpc.$.tsx — catch-all route for tRPC
import { createFileRoute } from '@tanstack/react-router'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '../../server/trpc/router'
import { createContext } from '../../server/trpc/context'
 
export const Route = createFileRoute('/api/trpc/$')({
  // This route acts as an API endpoint
})
 
export function handler(request: Request) {
  return fetchRequestHandler({
    endpoint: '/api/trpc',
    req: request,
    router: appRouter,
    createContext,
  })
}

For greenfield projects, TanStack Start's native server functions are simpler than adding tRPC. For migrations from Next.js with an existing tRPC setup, running tRPC as an adapter means you don't need to rewrite the backend.

TanStack Start vs Next.js App Router

The honest comparison after building real apps in both:

TanStack StartNext.js App Router
Routing type-safety✅ End-to-end typed paramsPartial — params is Promise<{}>
Search param types✅ Zod schema, automaticManual casting
Server data fetchingServer functionsServer Components + Actions
Vendor lock-inNone (Nitro adapters)Vercel-optimized
Ecosystem maturityGrowing (2026 stable)Mature
React ecosystemFull — any librarySome friction with use client
Deploy targetsAnywhere Nitro runsVercel-optimized, others possible
Learning curveTanStack Router conceptsReact Server Components

When Next.js is still the right choice:

  • You need the full Vercel ecosystem (ISR, Edge Middleware, image optimization)
  • The team already knows Next.js deeply
  • You're using next/image, next/font, or other Next.js-specific primitives heavily
  • Large codebase with existing Next.js patterns — not worth migrating

When TanStack Start wins:

  • Type safety is the top priority — typed params and search params matter
  • You want to deploy to Cloudflare Workers or other non-Vercel targets
  • You're already using TanStack Router and want a full-stack story
  • You want server-side data fetching without the React Server Components mental model
  • You've tried Remix but want more TypeScript integration

Authentication Pattern

Auth in TanStack Start follows the middleware pattern. Better Auth integrates cleanly:

// app/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from './db'
 
export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  emailAndPassword: { enabled: true },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }
  }
})
 
// app/lib/session.ts — get session in middleware
import { auth } from './auth'
import { getHeaders } from '@tanstack/start/server'
 
export async function getSession() {
  return auth.api.getSession({ headers: await getHeaders() })
}

Auth routes:

// app/routes/api/auth.$.tsx — Better Auth handles all auth routes
export function handler(request: Request) {
  return auth.handler(request)
}

Protected routes via beforeLoad:

// app/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getSession } from '../lib/session'
 
export const Route = createFileRoute('/dashboard')({
  beforeLoad: async () => {
    const session = await getSession()
    if (!session) {
      throw redirect({ to: '/login' })
    }
    return { user: session.user }
  },
  loader: ({ context }) => {
    // context.user is available from beforeLoad
    return fetchDashboard({ data: { userId: context.user.id } })
  },
  component: Dashboard,
})

Where TanStack Start Fits in the React Ecosystem

TanStack Start is mature and production-ready as of 2026. The v1 release is stable, the server function API is settled, and major companies are running it in production. It's not experimental — it's a deliberate full-stack answer to Next.js from the team that built TanStack Query and TanStack Router.

For React full-stack options in 2026:

  • Next.js — still the default, most ecosystem support, Vercel-optimized
  • Remix — excellent data loading model, less TypeScript integration
  • TanStack Start — best TypeScript experience, most flexible deployment, newer ecosystem

For new projects where type safety is a first-class requirement and you don't want to be tied to Vercel's infrastructure, TanStack Start is the serious alternative Next.js finally has.

For server actions patterns that work in TanStack Start and Next.js both, the patterns translate more cleanly than the framework differences suggest.

#tanstack#react#typescript#fullstack#routing
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.