JSON in TanStack Router and TanStack Start: Loaders, Server Functions, Search Params
Last updated:
TanStack Router is a fully type-safe React router; TanStack Start is the full-stack framework built on top of it. Both treat JSON not as untyped blobs but as values whose shape the type system tracks end-to-end — from the URL search string to the route loader return to the rendered component. This guide covers how JSON moves through a TanStack app: route loaders that fetch and return JSON with inferred types, server functions that run only on the server but feel like local async calls, search params validated by Zod schemas, integration with TanStack Query JSON caching, and streaming JSON via deferred data. As of 2026, TanStack Router 1.x is stable and TanStack Start is in active beta on the Vinxi runtime.
Debugging a JSON payload returned from a TanStack loader or server function? Paste the response into Jsonic's JSON Validator to catch malformed escapes, trailing commas in mock fixtures, and structural issues with exact line numbers.
Validate JSON ResponseTanStack Router vs TanStack Start: client-only vs full-stack
TanStack Router and TanStack Start are two separate but related packages from the same team. The router is a standalone React router that runs entirely in the browser — it owns route matching, navigation, search-param parsing, loader orchestration, and the typed route tree. You can drop it into any Vite or Create React App project and it replaces React Router with a stronger type system. The router itself knows nothing about servers; it fetches JSON the same way any client app does — over fetch against your existing API.
TanStack Start wraps the router with a full-stack framework. It ships SSR, file-based routing with auto-generated typed trees, server functions, request handlers, and a dev server built on Vinxi (a meta-framework built on Nitro). The same router APIs work — createFileRoute, loader, validateSearch— but loaders can now run on the server, server functions become callable, and SSR streams initial HTML plus loader JSON in one response. Think of Start as "router plus a server" rather than a separate framework.
The split lets you pick the right footprint. A client-side dashboard behind an existing API needs only the router. A new app that wants typed RPC and SSR from day one picks Start. Migration from router-only to Start is mechanical because the route and loader APIs are the same — you add server functions where you want them and the rest stays untouched.
Route loaders returning JSON with type inference
A route loader is a function attached to a route that runs before the component renders. It receives the parsed params, search, abort signal, and router context; its return value is the data the component reads. TanStack infers the loader return type into the component without any manual generic.
// src/routes/articles/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/articles/$slug')({
loader: async ({ params, abortController }) => {
const res = await fetch(`/api/articles/${params.slug}`, {
signal: abortController.signal,
})
if (!res.ok) throw new Error('Not found')
const article = (await res.json()) as {
id: string
title: string
body: string
publishedAt: string
}
return article
},
component: ArticlePage,
})
function ArticlePage() {
// article is fully typed — no useLoaderData<T>() generic needed
const article = Route.useLoaderData()
return (
<article>
<h1>{article.title}</h1>
<time>{article.publishedAt}</time>
<div>{article.body}</div>
</article>
)
}The router runs loaders in parallel with route matching and caches the result. Thedeps field controls cache invalidation — if you derive the loader input from search params, return them from deps so the cache refreshes when they change. The abort signal cancels in-flight fetches when the user navigates away mid-load.
Loaders can throw redirects (throw redirect({ to: '/login' })) and not-found responses (throw notFound()); the router renders the matching error or pending UI rather than the component. For more on shaping these payloads, see TypeScript JSON types.
Server functions ($serverFn) for full-stack JSON IO
Server functions are the TanStack Start RPC layer. You declare a function withcreateServerFn (or annotate a regular callable with the 'use server' directive) and the bundler emits an HTTP endpoint plus a client stub. The function body runs only on the server; the client imports a thin promise-returning wrapper that serializes the args to JSON, hits the endpoint, and parses the response.
// src/server/articles.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from './db'
const ArticleInput = z.object({
slug: z.string().min(1),
})
export const getArticle = createServerFn({ method: 'GET' })
.validator(ArticleInput.parse)
.handler(async ({ data }) => {
// Runs only on the server. db is never bundled to the client.
const article = await db.article.findUnique({
where: { slug: data.slug },
})
if (!article) throw new Error('Not found')
return article // JSON-serialized in the response
})Call the server function from any client code — a loader, a component event handler, a mutation, a custom hook. The return type is inferred from the handler.
// src/routes/articles/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getArticle } from '../../server/articles'
export const Route = createFileRoute('/articles/$slug')({
loader: ({ params }) => getArticle({ data: { slug: params.slug } }),
component: () => {
const article = Route.useLoaderData()
return <h1>{article.title}</h1>
},
})The validator field applies a Zod schema (or any function that parses input) before the handler runs. Invalid JSON from the client fails fast with a typed error rather than a runtime crash inside the handler.
Search params as typed JSON via Zod schemas
The query string is structured data, not just a string-to-string map. TanStack Router treats it that way — attach a validateSearch function to a route and the router parses the URL, validates the shape, and exposes a fully typed object viauseSearch. Zod is the recommended validator; the parsed type flows through every component that reads the search.
// src/routes/products/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().int().min(1).catch(1),
pageSize: z.number().int().min(10).max(100).catch(20),
sort: z.enum(['price-asc', 'price-desc', 'newest']).catch('newest'),
category: z.string().optional(),
tags: z.array(z.string()).catch([]),
})
export const Route = createFileRoute('/products/')({
validateSearch: productSearchSchema.parse,
loaderDeps: ({ search }) => search,
loader: async ({ deps }) => {
const url = new URL('/api/products', window.location.origin)
url.searchParams.set('page', String(deps.page))
url.searchParams.set('sort', deps.sort)
if (deps.category) url.searchParams.set('category', deps.category)
const res = await fetch(url)
return (await res.json()) as { items: Product[]; total: number }
},
component: ProductList,
})
function ProductList() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
return (
<button
onClick={() =>
navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) })
}
>
Page {search.page + 1}
</button>
)
}The catch calls on individual Zod fields supply defaults so a hand-typed URL with a missing or malformed value still produces a valid search object instead of throwing. The navigate call rejects unknown keys at compile time — try to pass order when the schema declares sort and TypeScript fails the build.
Integration with TanStack Query for caching
TanStack Router handles route-scoped data loading; TanStack Query handles the long-lived client cache, background refetching, optimistic updates, and request deduplication. The two compose by passing the queryClient through router context so loaders can prime the cache before the component mounts.
// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: { queryClient }, // Available in every loader
})
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)
}// src/routes/articles/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { articleQuery } from '../../queries/articles'
export const Route = createFileRoute('/articles/$slug')({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData(articleQuery(params.slug)),
component: ArticlePage,
})
function ArticlePage() {
const { slug } = Route.useParams()
const { data: article } = useQuery(articleQuery(slug))
return <h1>{article!.title}</h1>
}The pattern: define articleQuery(slug) once (a function returning a query options object with queryKey and queryFn), callensureQueryData in the loader to block route render until the data is warm, and call useQuery in the component to subscribe to the same cache entry. Background refetch, stale-while-revalidate, and mutation invalidation all work the way TanStack Query users expect.
Streaming JSON with deferred data
Some pages have one part that loads fast and another that takes longer. Blocking the whole route on the slow piece is the wrong default — the user waits with a spinner when the header, navigation, and primary content are ready to render. TanStack supports deferred data: the loader returns a mix of awaited values and unresolved promises, the router renders as soon as the awaited values are ready, and the deferred promises stream to the client and unblock Suspense fallbacks as they resolve.
// src/routes/dashboard/index.tsx
import { createFileRoute, defer, Await } from '@tanstack/react-router'
import { Suspense } from 'react'
export const Route = createFileRoute('/dashboard/')({
loader: async () => {
// Critical data: awaited
const user = await fetch('/api/me').then((r) => r.json())
// Slow data: returned as a promise; streams in later
const recommendationsPromise = fetch('/api/recommendations').then((r) =>
r.json(),
)
return {
user,
recommendations: defer(recommendationsPromise),
}
},
component: DashboardPage,
})
function DashboardPage() {
const { user, recommendations } = Route.useLoaderData()
return (
<>
<h1>Welcome, {user.name}</h1>
<Suspense fallback={<p>Loading recommendations...</p>}>
<Await promise={recommendations}>
{(items) => (
<ul>
{items.map((i) => (
<li key={i.id}>{i.title}</li>
))}
</ul>
)}
</Await>
</Suspense>
</>
)
}In TanStack Start, the deferred JSON streams over the same SSR response — the critical HTML reaches the browser first, then the slower payload arrives in-band without a second network round-trip. The API mirrors the deferred pattern inRemix / React Router 7, so the mental model ports across frameworks.
Type-safe route paths and JSON contract
TanStack's biggest selling point is end-to-end type safety on the URL itself. The file-based router CLI watches your src/routes folder and generates arouteTree.gen.ts file that types every path string, every param, and every search-param shape. Navigation calls, link components, and loader contexts all use that generated tree — a missing param or a typo in a path string fails at compile time.
import { Link } from '@tanstack/react-router'
// Compile-time error: '/articles/{slug}' is not a route in the generated tree
<Link to="/articles/{slug}" /> // error
// Compile-time error: 'slug' param is required for '/articles/$slug'
<Link to="/articles/$slug" /> // error — missing params
// Compile-time error: search schema declares 'page', not 'p'
<Link to="/products" search={{ p: 1 }} /> // error
// Correct
<Link to="/articles/$slug" params={{ slug: 'hello' }} />
<Link to="/products" search={{ page: 2, sort: 'newest' }} />The same typing extends to the JSON contract. Loader return values are inferred to the component, search-param schemas drive both URL parsing and navigation autocomplete, and server functions carry both input and output types from server to client. The effect is that the JSON shape your app produces and consumes is documented by the type system rather than by README files — refactor a server function's return shape and every consumer that reads the stale field fails the build.
Pair this with Zod schemas at the edges (HTTP request bodies, third-party API responses, localStorage reads) and you get runtime validation on the outside, full TypeScript inference on the inside.
TanStack Router vs React Router 7 vs Next.js App Router
The three routers share a vocabulary — routes, loaders, layouts, search params — but the architectures pull in different directions. The table below summarizes how each one handles JSON loading and server-side code as of 2026.
| Concern | TanStack Router / Start | React Router 7 (Remix) | Next.js App Router |
|---|---|---|---|
| Type safety on paths | Full — generated route tree | Partial — typegen via plugin | Partial — typed routes via experimental flag |
| Search param typing | First-class via validateSearch | Read manually from URLSearchParams | Read manually via useSearchParams |
| Server code unit | Server function (createServerFn) | Loader / action | RSC + server action |
| Streaming | Deferred values + <Await> | Deferred values + <Await> | RSC streaming + Suspense |
| Client cache | Pairs with TanStack Query | Router cache + revalidation | React cache + Cache Components |
| Maturity (2026) | Router: stable. Start: beta. | Stable | Stable (Next.js 16) |
For client-rich apps with rich URL state, TanStack's typed search params are the standout feature. For server-first apps that want streaming HTML and minimal client JavaScript, Next.js App Router with RSC is the better fit. Remix / React Router 7 sits in the middle — server loaders with a thinner client cache than TanStack Query and a simpler RPC story than server functions.
Key terms
- TanStack Router
- A fully type-safe React router with file-based or code-based routing, typed search params, and route-scoped loaders. Runs in the browser; stable at 1.x as of 2026.
- TanStack Start
- The full-stack framework built on TanStack Router. Adds SSR, server functions, and a Vinxi-based dev server. In beta as of 2026.
- Route loader
- A function attached to a route that runs before the component renders. Returns JSON-serializable data with full type inference into the component via
useLoaderData. - Server function
- A TypeScript function in TanStack Start that runs only on the server but is callable from any client code. Defined with
createServerFnor the'use server'directive; arguments and return values are JSON-serialized. - validateSearch
- A function attached to a route (typically a Zod parse) that converts the URL search string into a typed object. Drives both URL parsing and navigation autocomplete.
- defer
- A helper that marks a promise inside a loader return value as deferred — the route renders without waiting, and the promise streams in to fill a
<Await>boundary. - routeTree.gen.ts
- The generated TypeScript file the TanStack Router CLI emits from your routes folder. Encodes every path, param, and search schema for compile-time route safety.
Frequently asked questions
What is TanStack Router?
TanStack Router is a fully type-safe React router built by Tanner Linsley and the TanStack team. It treats route paths, search parameters, loader return values, and route context as typed values — the type system tracks every parameter from the URL to the rendered component, so a typo in a search param key or a missing loader argument fails at edit time rather than runtime. TanStack Router runs entirely on the client (in single-page-app mode) and pairs with any data layer, though it has first-class support for TanStack Query. It supports both file-based routing (a CLI watches your routes folder and generates a typed route tree) and code-based routing (you compose routes by hand). Version 1.x is stable and production-ready. TanStack Start is the separate full-stack framework that builds on top of the same router — see the next question for the difference.
How does TanStack Router handle JSON loading?
Each route can declare a loader function that runs before the route component renders. The loader receives parsed params and search params (already typed) and returns any value — usually a JSON payload fetched from an API. The return type is inferred all the way to the component via useLoaderData, so you never write data types by hand. Loaders run in parallel with route matching, support abort signals through the deps argument, and integrate with the router cache so revisiting a route does not re-fetch unless dependencies change. For client-only apps, the loader runs in the browser and fetches JSON over HTTP like any React component would. For TanStack Start, the same loader can be marked to run on the server, which lets you call databases or private APIs directly without a separate API route. The pattern resembles Remix loaders but with stronger type inference and a more granular cache.
What is a server function in TanStack Start?
A server function is a TypeScript function that runs only on the server but can be called from the client as if it were local. You define it with createServerFn (or the 'use server' directive on a callable) and call it like a normal async function from a component, hook, or route loader. The TanStack Start bundler splits the file so the server function body never ships to the browser — only a thin RPC stub remains. The return value is JSON-serialized over HTTP, type-checked end-to-end. Server functions accept input validation (Zod is the recommended path), middleware (auth, logging), and can be invoked from loaders, components, mutations, or anywhere else. The pattern is similar to Next.js server actions but with explicit JSON serialization, no implicit form-action binding, and full client-side callability via promises.
How do typed search params work?
TanStack Router treats the query string as structured data, not just a string-to-string map. You attach a validateSearch schema to each route — typically a Zod schema — and the router parses the URL, validates against the schema, and exposes the result as a typed object via useSearch. Optional fields default cleanly, unknown keys can be stripped or preserved, and invalid values can either throw or coerce to defaults. Navigation that updates search params is also typed: the navigate call refuses to accept a field name the schema does not declare. Arrays and nested objects encode as JSON-stringified params (the default serializer) or with a custom parser if you prefer a flatter scheme. Type inference means a component reading useSearch sees the exact shape its route declared, even when descending through nested layouts.
Should I use TanStack Query alongside TanStack Router?
Yes — they are designed to compose. The router handles route matching, loader orchestration, and search-param typing; TanStack Query handles cache invalidation, background refetching, optimistic updates, and request deduplication. The recommended pattern is to call queryClient.ensureQueryData inside the route loader, which makes the router wait for the query before rendering and primes the TanStack Query cache. Components then call useQuery with the same query key and get the cached data instantly, with automatic background refresh on focus or stale-time expiry. This split gives you Suspense-style data loading at the route boundary plus rich client-side cache behavior inside the page. The TanStack Router docs ship an integration helper (the router context pattern) that injects the queryClient so loaders can access it without prop-drilling.
How does TanStack Start compare to Next.js App Router?
Both are full-stack React frameworks but the architecture differs. Next.js App Router treats React Server Components as the primary unit — components render on the server, stream HTML and RSC payloads, and you call server functions via the 'use server' directive. TanStack Start builds on TanStack Router and treats the route loader (a client- or server-runnable function returning JSON) as the primary unit, with server functions as the RPC layer. As of 2026, TanStack Start is in beta, built on Vinxi (a Nitro-based meta-framework) and supports streaming SSR, server functions, and the same typed router from the client-only version. The Start ecosystem leans on TanStack Query for client caching where Next leans on React cache primitives and Cache Components. Pick Next when you want RSC and the broader ecosystem; pick Start when you want end-to-end type safety driven from the router and explicit loader semantics.
Can TanStack Router stream JSON responses?
Yes. TanStack Router supports deferred data — a pattern where the loader returns an object containing both resolved values and unresolved promises. The router renders the route as soon as the critical data is ready and streams the deferred values to the client as they resolve. Components subscribe to a deferred value with useAwait or wrap it in an Await component (similar to React Router 7's defer API) and Suspense renders the fallback until the promise settles. This is the right pattern when one part of a page (header, navigation, primary content) loads fast but another part (recommendations, analytics, AI-generated summaries) takes longer. In TanStack Start, deferred data streams over the same SSR response so the user sees the critical content immediately and the slower JSON arrives in-band without a second request.
How do I share types between client and server in TanStack Start?
Server functions in TanStack Start return values that the client imports as ordinary async functions, so TypeScript infers the return type from the server-side body. You define the function once, export it, and call it from the client — the bundler strips the body from the client bundle but keeps the type signature. Combined with Zod input validation (createServerFn().input(schema).handler(fn)), you get bidirectional type safety: the server validates incoming JSON and the client sees the parsed input type plus the return type. For shapes shared across multiple endpoints (a User type, an Article type), define a Zod schema in a shared module and use z.infer to derive the TypeScript type. This avoids the duplicate-type problem that plagues many full-stack apps where the server and client maintain separate but identical interface declarations.
Further reading and primary sources
- TanStack Router Docs — Official documentation — routes, loaders, search params, and the generated route tree
- TanStack Start Docs — Full-stack framework on Vinxi — SSR, server functions, and request handlers
- TanStack Query Docs — Client-side data cache that composes with the router via ensureQueryData
- Zod Documentation — TypeScript-first schema validation used for searchParams and server function input
- Vinxi — The Nitro-based meta-framework that powers TanStack Start