Home

TanStack Start and End-to-End Type Safety

What connects a server function to the code that calls it?

·26 min read
01Oct 3How TanStack Start runs your routesWhat Start runs, and what it sends back
02Oct 11TanStack Start and End-to-End Type SafetyWhat connects a server function to the code that calls it?

In TanStack Start, a page can call a function that runs only on the server. If that function returns a title and we rename it to heading, TypeScript can flag the page still reading title. The browser never receives the server function's implementation, so how does the type checker know what it returns?


TanStack Start is a React framework built on TanStack Router, which handles routes and page loading. Its end-to-end type safety checks both the arguments the page passes and how it uses the result against the server function's definition. The page does not need a separately maintained description of that response.

🔖 TL;DR
  • Start infers a server function's input and result types from its definition. Changing what it returns can reveal errors in the page that uses it, before either runs.
  • The browser calls a generated version of that function, which sends an HTTP request. TypeScript checks the original function and its callers; Start handles sending the arguments and returning the result.
  • Next also checks server-call arguments and results, and can check link paths with typedRoutes enabled. TanStack Router additionally uses a route's search schema to check links: if a page accepts only view=read or view=edit, a link passing view=history is a type error.
  • Types do not check incoming requests or list every error a function might throw. Validators check incoming values at runtime; returning an expected failure makes it part of the result type callers can check.

Two TypeScript files can still disagree

Consider a notes app. Each note has a title and some text, and opening /notes/n1 shows the note with ID n1.


Suppose an HTTP endpoint returns the note, and the page loads it like this:

ts
const response = await fetch('/api/notes/n1')
const note = await response.json()

document.title = note.title

The endpoint might be written in TypeScript, but nothing in this call refers to its implementation. fetch receives a URL string. TypeScript's definition of response.json() returns Promise<any>, so the compiler allows note.title without knowing whether the response has a title at all.


Rename the endpoint's response field to heading, and this code still passes its type check. At runtime, note.title is now undefined.

endpoint.ts
return Response.json({  id: 'n1',Removed:   title: 'First note',Added:   heading: 'First note',})
page.ts
const note = await response.json()note.title
Console
note.titleundefined

We can give the response a type ourselves:

ts
type Note = { id: string; title: string }
const note = await response.json() as Note

Now the editor knows about title. But the as Note assertion asks TypeScript to trust us; it does not check the response or connect it to the endpoint. We have described what we expect to receive without establishing that the server returns it.


We want the type checker to compare the page's use of title with what the server actually returns, instead of trusting the page's as Note assertion.

Following the server's return value

In Start, the page can import a server function. createServerFn defines the function, and the code inside .handler(...) runs on the server even when the caller is in the browser.


Here, readNote reads a note from storage. It returns a note with string id and title fields, or throws if the note does not exist. The server function returns those fields to the page:

ts
// src/notes.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { readNote } from './notes.server'

export const getNote = createServerFn({ method: 'GET' })
  .validator(z.object({ id: z.string().min(1) }))
  .handler(async ({ data }) => {
    const note = await readNote(data.id)
    return { id: note.id, title: note.title }
  })

The Zod schema describes an object with a nonempty string id. It provides executable validation code and TypeScript types for the values it accepts. validator attaches that schema to the server function; handler supplies the operation to perform after validation.


The caller imports getNote from this module:

ts
import { getNote } from './notes'

const note = await getNote({ data: { id: 'n1' } })
note.title

There is no response interface to maintain here. TypeScript infers the object returned by the handler, and Start carries that type into the exported function's result. After await, note has the type { id: string; title: string }.


Change the handler's returned field from title to heading. The caller now gets an error where it reads the old field:

notes.ts
return {  id: note.id,Removed:   title: note.title,Added:   heading: note.title,}
page.ts1
const note = await getNote({  data: { id: 'n1' },})note.title
Property 'title' does not exist on type{ id: string; heading: string; }

The input is connected too. Start uses the validator's input type for the caller's data option, so getNote({ data: { id: 42 } }) is a type error. Inside the handler, data has the validator's output type: the type of the value produced after validation.


How does Start connect the handler's return value to the function we call? Its type definition uses a generic type parameter: a name for a type that TypeScript can fill in from our code. Start calls this parameter TNewResponse.


Start's ServerFn type describes the callback we pass to .handler(). Its Fetcher type describes the function we get back, which we assign to getNote. The .handler() declaration uses the same TNewResponse in both types.


Here is our getNote beside Start's .handler() declaration. The … marks omitted configuration and type arguments:

notes.ts
const getNote = createServerFn(…)  .validator(…)  .handler(async ({ data }) => {    const note =      await readNote(data.id)    return {      id: note.id,      title: note.title,    }  }) const note = await getNote({  data: { id: 'n1' },})note.title
createServerFn.ts
TanStack Start
handler: <TNewResponse>(  fn?: ServerFn<    …, TNewResponse, …  >) => Fetcher<…, TNewResponse>

Because the callback is async, its return type is Promise<{ id: string; title: string }>. TypeScript infers that type for TNewResponse and carries it into the returned Fetcher. We do not write a separate return type for getNote.


Inside Fetcher, that response parameter is called TResponse. This is the same type passed from .handler(), under the name used in Fetcher's own definition. Its call signature returns Promise<Awaited<TResponse>>.


Awaited obtains the object type from the callback's promise. The outer Promise describes the asynchronous call to getNote, so getNote({ data: { id: 'n1' } }) returns Promise<{ id: string; title: string }>. Awaiting that call gives the caller the note. The .handler() declaration and returned function's call signature show the two ends of this connection.


Renaming title to heading changes the callback's return type. Because the caller's result comes from that same type, TypeScript can flag code that still reads note.title. It follows the definitions and imports in our source files; it does not need to execute the handler or inspect an HTTP response.

What the browser actually imports

The page imports getNote from ./notes, where we wrote the storage read. But the browser downloads the JavaScript produced by Start's build. Start keeps the handler in the server build and replaces it with request code in the browser build.


The page still makes the same call:

ts
const note = await getNote({ data: { id: 'n1' } })

In the browser, this version of getNote sends a GET request containing the note ID to /_serverFn/<function-id>. Start generates the function ID during the build and puts it in the request code. The server uses it to find getNote's handler; the handler uses our 'n1' argument to find the note. Calling with 'n2' reaches the same server function with a different note ID.


The server checks the input with the validator, runs the handler, and sends its result back. Start's browser code reads that response and resolves the promise returned by getNote. The await then gives the page the note. The browser never runs readNote itself.


The editor's TypeScript checker reads our source files, where getNote still contains the handler. It follows the page's import to that definition and infers the returned fields there. Start changes the JavaScript sent to the browser; it does not replace the source definition that gives TypeScript the type of note.title.

Carrying the result into the page

Start uses TanStack Router to match URLs, load data for the matched routes, and render their components. A loader is the function a route runs to obtain its data before rendering that component.


Our example uses file-based routing. TanStack reads the names and locations of files under src/routes to determine the routes. Here, notes.$noteId.tsx corresponds to /notes/$noteId: the dot separates path segments and $noteId marks a variable segment. The generator maintains the path passed to createFileRoute; we supply the loader and component:

tsx
// src/routes/notes.$noteId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getNote } from '../notes'

export const Route = createFileRoute('/notes/$noteId')({
  loader: ({ params }) =>
    getNote({ data: { id: params.noteId } }),
  component: NotePage,
})

function NotePage() {
  const note = Route.useLoaderData()
  return <h1>{note.title}</h1>
}

For /notes/n1, the loader receives params.noteId as the string 'n1'. It passes that ID to getNote and returns its promise. The router waits for the result before rendering NotePage through this route.


Route.useLoaderData() reads that result. Its type comes from this route's loader, whose result comes from getNote, whose result comes from the handler. We have not declared the note's response shape again at any of those steps.

notes.ts
export const getNote = …  .handler(async (…) => {    …    return {      id: note.id,      title: note.title,    }  })
notes.$noteId.tsx
const Route = …({  loader: (…) => getNote(…),}) // Inside NotePageconst note =  Route.useLoaderData()note.
idstring
titlestring

The earlier title to heading change now produces an error in the JSX too. If the loader instead returns { note: await getNote(...) }, then const data = Route.useLoaderData() gives the component that object, and the title is available as data.note.title. The router follows what the loader actually returns, including transformations we make there.


The browser can run this loader when navigating to another note. On an initial server-rendered visit, the loader can run on the server. In either case, TypeScript gets the type returned by Route.useLoaderData() from the same loader definition.

A type error cannot reject an HTTP request

TypeScript reports an error when our caller passes id: 42. Someone can still send that value from plain JavaScript or construct a request directly. They do not have to run our type checker first.


That is the second job of the validator. Start runs the Zod schema on the server before the handler. A numeric id fails the string check, so the handler does not run. The error is returned to the caller, and the server-function promise rejects. With valid input, the handler receives the schema's validated output.


Validation can also reject values that pass the type checker. id: '' is a string, so TypeScript accepts it. The schema's .min(1) check rejects it at runtime. The inferred string type does not express the minimum-length rule.


Attaching a function called a validator is not sufficient by itself:

ts
.validator((data: { id: string }) => data)

This function tells TypeScript that data.id is a string, then returns whatever it receives. Its type annotation disappears from JavaScript. There is no test that could reject a number. The Zod schema actually checks the value before returning it to the handler.


The input and output types can differ. A schema might accept a string and convert it to a number. The caller must supply the string; the handler gets the number. Start keeps both types because the code before validation and the code after it work with different values.

Returning a Date to the browser

Suppose each note now records when it was last edited. readNote returns that time in an updatedAt field as a JavaScript Date. We include it in getNote's handler result:

ts
return {
  id: note.id,
  title: note.title,
  updatedAt: note.updatedAt,
}

TypeScript infers updatedAt: Date. The page can call its toLocaleDateString() method to display the last-edited date:

ts
const note = await getNote({ data: { id: 'n1' } })
note.updatedAt.toLocaleDateString()

That method needs an actual Date object in the browser. With an ordinary JSON endpoint, the server would convert the Date to a string. The response would contain text like this:

json
{
  "id": "n1",
  "title": "First note",
  "updatedAt": "2026-10-12T00:00:00.000Z"
}

Parsing that JSON returns a string for updatedAt. The text looks like a date to us, but JSON has no Date type, and the parser does not turn date-looking strings into Date objects. Calling toLocaleDateString() on that string would fail. A TypeScript annotation cannot restore the missing method.


Start uses serialization code to encode the returned values for the response and reconstruct them in the browser. For a Date, it sends a marker identifying the value as a Date along with its timestamp. The browser reads that marker and creates a new Date representing the same time before resolving getNote. The page receives an object with the method TypeScript allowed it to call.


This works for values Start knows how to encode and reconstruct. Its server-function types reject an ordinary callback such as { run: () => 1 }. A callback could depend on variables available only in the server process; sending its source would not recreate those variables in the browser.

Passing a user from middleware to the handler

A valid note ID does not tell us whether the current user may read that note. For private notes, the server also needs to identify the user and check their access. Reading a note and editing one both need the session check, so we can put it in function middleware: reusable code that runs as part of a server-function call.


In this version of the app, getSessionUser is our helper that checks the current request's session. It returns a user with a string id, or null if there is no valid session. We call it in server middleware:

ts
import { createMiddleware } from '@tanstack/react-start'
import { getSessionUser } from './notes.server'

const requireUser = createMiddleware({ type: 'function' })
  .server(async ({ next }) => {
    const user = await getSessionUser()
    if (!user) throw new Error('Unauthorized')

    return next({ context: { user } })
  })

.server(...) supplies the code to run on the server. If the session is missing, throwing stops the call. Otherwise, next continues to the next step and passes along the user inside context. The handler can use that object without repeating the session check.


We attach this middleware to getNote. Our storage helper also changes: readUserNote takes a note ID and a user ID, checks that the user may read that note, and returns it only if they have access.

ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { readUserNote } from './notes.server'

export const getNote = createServerFn({ method: 'GET' })
  .middleware([requireUser])
  .validator(z.object({ id: z.string().min(1) }))
  .handler(({ data, context }) =>
    readUserNote(data.id, context.user.id),
  )

For this call, Start first runs requireUser. If it calls next, Start validates the note ID and then runs the handler. data.id comes from the caller and passes through the validator; context.user comes from the server's session check. The caller still sends only getNote({ data: { id: 'n1' } }).


The handler knows the type of context.user without a separate interface. The if (!user) guard first narrows user from { id: string } | null to { id: string }. Like the handler method earlier, next uses generics to keep track of a value's type. Its return type includes the type of the context we supplied. Returning that result from .server(...) lets Start retain { user: { id: string } } in the middleware's type. When we attach requireUser, Start uses that type for the handler's context.


If we change next({ context: { user } }) to pass { account: user }, the handler's context.user becomes a type error. Removing .middleware([requireUser]) also makes it an error. The middleware type definitions connect the context passed to next with the result of .server(...).


This user object stays in the server call. Adding it to context does not add it to the note returned to the browser. The caller's result type still comes from the handler's return value.


TypeScript checks which properties the middleware provides and how the handler uses them. The actual session and permission checks remain the work of getSessionUser and readUserNote; an object with an id property is not proof that a request is authorized.

A thrown error is not an inferred result

A server function's return type describes what its promise resolves to. It does not list the errors that could reject that promise. If requireUser throws because the session is missing, TypeScript still treats the value from a successful await getNote(...) as a note.


TypeScript's Promise has one type parameter for that resolved value. There is no second parameter that Start can fill with every possible exception. With strict type checking, the value in a catch block is unknown; we have to check it before reading properties such as message. Input validation can reject a call, and a network request can fail, without either failure appearing in the handler's inferred return type.


We can include an expected failure in the return type by returning it as a value. For a lookup that needs to distinguish a missing note from a successful result, consider a separate server function, lookupNote. Here findNote returns undefined when the note is absent:

ts
export const lookupNote = createServerFn({ method: 'GET' })
  .validator(z.object({ id: z.string().min(1) }))
  .handler(async ({ data }) => {
    const note = await findNote(data.id)
    if (!note) {
      return { status: 'not-found' as const }
    }
    return { status: 'found' as const, note }
  })

The two returned objects have different status values. as const preserves those literal strings in the inferred type, so TypeScript can use the value of status to determine which object the caller has:

ts
const result = await lookupNote({ data: { id: 'n1' } })

if (result.status === 'found') {
  document.title = result.note.title
} else {
  document.title = 'Note not found'
}

The caller can use result.note.title in the 'found' branch. Trying to read result.note.title in the 'not-found' branch is a type error. Start carries both returned shapes across the server-function call through the same inference and serialization used for a successful note.


TypeScript can now check which fields the caller reads after a missing-note result. Validation failures and unexpected exceptions can still reject the call. The same distinction applies to Next Server Actions. Next's error-handling guide also recommends returning expected failures as values.

The note's type now reaches the component. The page also receives data from its URL: /notes/n1?view=edit should select the editor, while a missing view should open the reading view. The route can check both the value it reads from that URL and the code that creates a link to it.


This value comes from the address bar, so the page needs to handle values it did not create. We give the route a validateSearch schema. Zod's enum restricts view to these two strings:

tsx
import { z } from 'zod'

export const Route = createFileRoute('/notes/$noteId')({
  validateSearch: z.object({
    view: z.enum(['read', 'edit']).default('read'),
  }),
  loader: ({ params }) =>
    getNote({ data: { id: params.noteId } }),
  component: NotePage,
})

The schema accepts 'read' or 'edit' and supplies 'read' when the field is absent. Another value fails validation. Inside NotePage, Route.useSearch().view is consequently 'read' | 'edit', with no undefined case to handle.


The same definition guides code that creates a Link to this route. Once to selects the note route, the editor knows which values to suggest for view:

note-link.tsx
<Link  to="/notes/$noteId"  params={{ noteId: 'n1' }}  search={{ view: 'edit' }}>  Edit note</Link>
"read"
"edit"

to selects the route, params fills its path segment, and search supplies its search values. Together they produce /notes/n1?view=edit. TypeScript checks each part against the selected route:

Invalid inputWhy it fails
"/notse/$noteId"That route does not exist in the route tree.
params.idThe route requires a parameter called noteId.
view: 'history'The search schema accepts only 'read' or 'edit'.

The default also lets a link omit view. search={{}} is allowed because the schema supplies the missing value. The link's input may omit it, while the page's validated output always has it.


Changing the schema can now reveal errors in links to the page. If we replace 'edit' with 'write', links still passing 'edit' become type errors. The parser and the links are working from the same definition.

Link comes from a library. Our note route belongs to the application. Something has to connect them before the library can reject a nonexistent route or know its search schema.


The file-based routing generator also writes routeTree.gen.ts. It imports each file's Route object and assembles a route tree, which records which routes are nested under others. Our note route is attached to the root route shared by all pages. The generated FileRoutesByFullPath interface also maps complete paths to those route definitions:

ts
export interface FileRoutesByFullPath {
  '/notes/$noteId': typeof NotesNoteIdRoute
  // ...
}

NotesNoteIdRoute is the generated file's name for our route from notes.$noteId.tsx. typeof NotesNoteIdRoute obtains that route object's type, including the information inferred from its loader and search validator. The generated file does not need to copy the note response into a separate interface.


The application passes the assembled tree to createRouter. Its return type includes the inferred type of that tree, with each route's loader and search types:

tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  return createRouter({ routeTree })
}

Start also generates the type registration in routeTree.gen.ts. This excerpt connects the type returned by our getRouter function to the library's Register interface:

ts
import type { getRouter } from './router.tsx'

declare module '@tanstack/react-start' {
  interface Register {
    // ...
    router: Awaited<ReturnType<typeof getRouter>>
  }
}

declare module adds our router property to the library's exported Register interface. This is TypeScript's module augmentation: an application can extend a library's interface without editing the installed library. ReturnType obtains the type returned by getRouter, and Awaited also handles a router returned asynchronously. Neither runs the function.


Start and TanStack Router both re-export the same Register interface from Router's core package. Adding the router type through Start therefore also makes it available to Router's Link types. createRouter creates the router used at runtime; this declaration makes its type available to Link. The declaration itself produces no browser JavaScript.


Link infers the literal path supplied to to, then uses the registered router type to select that route. For to="/notes/$noteId", selecting the route and its accepted search values looks like this:

ts
type NoteRoute = FileRoutesByFullPath['/notes/$noteId']
type NoteSearch = NoteRoute['types']['fullSearchSchemaInput']
// { view?: 'read' | 'edit' }

NoteRoute selects the route type from the generated path map. Its types member describes the values inferred when the route was defined; fullSearchSchemaInput is the accepted search input, including any inherited from parent routes. Here NoteSearch allows the two strings and permits view to be absent because the validator supplies a default.


The library performs that lookup in its generic types and checks the search prop against the result. The same imported Link can therefore check the search values for each destination. Route.useLoaderData() already identifies a route, so its types can read the loader result from that route directly.


At runtime, the router uses route objects to match URLs and run loaders. During type checking, the registered route types let TypeScript reject params.id or view: 'history' in a link to this note route.

What Next.js already connects

Next's App Router also preserves types between server code and the page. A Server Component runs on the server and can call the storage function directly:

tsx
// app/notes/[noteId]/page.tsx
import { readNote } from '@/notes.server'

export default async function NotePage(
  { params }: PageProps<'/notes/[noteId]'>
) {
  const { noteId } = await params
  const note = await readNote(noteId)
  return <h1>{note.title}</h1>
}

Both NotePage and readNote run on the server. Their call is an ordinary TypeScript function call, so the returned note is inferred. Renaming its title field makes the JSX fail its type check here too. There is no JSON endpoint between these two functions.


PageProps is a generated Next helper. The route path tells it that params contains a string noteId.


The frameworks organize the read differently. Start's route component reads a loader result and can render in the browser; a browser-side loader calls the generated server-function endpoint when it needs the note. This Next page reads the note while rendering a Server Component on the server. Next sends the rendered Server Component result for the browser to incorporate into the page. Both can preserve the type from storage to the JSX; they do not have to use the same execution model to do it.


A browser-triggered mutation needs a server call in Next too. A Server Action can retain the original function's argument and result types:

ts
// app/notes/actions.ts
'use server'

export async function renameNote(id: string, title: string) {
  // Validate input, authorize the edit, and update storage.
  return { id, title }
}

Client code can import renameNote. TypeScript rejects a number in place of title and infers { id: string; title: string } from its returned value. Renaming that returned field also affects typed callers. The parameter annotations alone do not validate a request; the action must perform the checks described in the comment.


Like Start, Next transforms the browser implementation. It generates a server reference containing an identifier. Calling that reference sends the arguments to the server, which finds the action and executes it. The browser's promise receives the returned result through React's serialization protocol. TypeScript reads the original exported function's argument and return types; Next supplies the code that sends the arguments and returns the result.


Both frameworks can therefore check the arguments and results of a function the browser calls on the server. Next's Server Actions guide describes that mutation path, while its type safety guide describes typed data flowing through Server Components.

Where their route types differ

Next can also check link paths. With typedRoutes: true in its configuration, a misspelled route string in Link is a type error. Current Next generates route-aware page props and checks App Router navigation methods as well.


That still leaves the note page's view setting:

tsx
<Link href="/notes/n1?view=history">
  Open note
</Link>

The path can match /notes/[noteId], so this href passes the route check. Next's generated route types do not know that this application's view must be 'read' or 'edit'. They also do not reject a misspelled query key such as veiw.


On the receiving page, Next's generated searchParams type describes raw URL values:

ts
Promise<Record<string, string | string[] | undefined>>

A key can be absent, contain one value, or appear more than once. The application must check that view is 'read' or 'edit' before using it to choose what to render. A Zod schema can perform the same validation and defaulting as in Start, but that local parser does not automatically change what Next's Link accepts elsewhere.


In TanStack Router, changing the allowed view value from 'edit' to 'write' also makes links still passing search={{ view: 'edit' }} fail their type check. In Next, changing the page's parser does not make <Link href="/notes/n1?view=edit"> fail its path check. The path is still valid even though the parser no longer accepts that query value.


The checks available to the notes app are:

CheckStartNext
Result fieldsInferredInferred
Literal pathsBuilt inOpt-in
Link searchRoute schemaCustom URL helper

A Next application can build a typed URL helper that accepts the parser's input type. Links must use that helper to receive those query-value checks; Next's generated route types do not obtain them from the page's parser automatically.

Keeping the connection intact

These checks depend on the type returned by readNote. If that function returns unchecked response.json() from another service, its result is any. A Start handler returning that result cannot infer which fields really exist. Writing as Note would hide the uncertainty again.


For an external response, validate the value with a schema and return the checked result from the handler. For a database, a typed query can derive its result fields from the columns it selects, provided its schema describes the database correctly. Start can then use those types for the server call, loader result and component.


The route's search validator checks URL values before the page uses them. For expected failures, a returned status lets TypeScript check which fields are available in each branch. Neither replaces handling a failed network request or an unexpected exception.


Renaming a server response field should reveal the components that still read the old name. Removing an allowed search value should reveal the links that still send it. Start makes those edits visible to TypeScript by deriving the caller's result from the handler, and the link's props from its route. We still write the checks for incoming values; we no longer have to repeat their types at every place that uses them.