Skip to main content
In the web-only shape there’s no apps/api and no oRPC client. Your backend logic lives in server functions: typed async functions you write once and call from the client, while the body only ever runs on the server.
API as a function. No routes to register, no client to generate — createServerFn(...).handler(...), then call it.
This is TanStack Start’s createServerFn. Ship wires it up for you; this page is the one obvious way to use it.

SPA mode still has a server

Ship runs apps/web in SPA mode — see vite.config.ts:
vite.config.ts
tanstackStart({
  spa: {
    enabled: true,
    prerender: { outputPath: 'index.html' },
  },
}),
SPA mode does not disable the Start server. It only turns off server-rendering your routes — the routes ship as a static SPA. The TanStack Start (Nitro) server is still there, and it’s exactly where your server functions execute. Web-only means no separate API app, not no server.

Import from @tanstack/react-start

createServerFn comes from @tanstack/react-start — the framework package — not from @tanstack/react-router.
The footgun: @tanstack/react-router is the package you import createFileRoute, useNavigate and Link from. It does not export createServerFn. Reach for the router package out of habit and you’ll get an undefined import that fails at runtime. Server functions live in @tanstack/react-start.
// ✅ server functions
import { createServerFn } from '@tanstack/react-start';

// ✅ routes, links, navigation — a different package
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';

The shipped example

The web-only template ships one server function at apps/web/src/server/greeting.ts so you have a working pattern to copy:
apps/web/src/server/greeting.ts
import { createServerFn } from '@tanstack/react-start';

export const getGreeting = createServerFn({ method: 'GET' }).handler(async () => {
  return { message: 'Hello from the Start server' };
});
Three things define it:
  • createServerFn({ method })'GET' for reads, 'POST' for writes. The method is how the client transports the call to the server.
  • .handler(async (...) => ...) — the body. It runs only on the server, so this is where you reach for secrets, a database, or any server-only dependency. Nothing here ships to the browser.
  • The return value is sent back to the caller, fully typed end-to-end. The client sees the handler’s return type with no codegen.

Consume it from a route loader

A server function is just an async function, so you can call it anywhere. The idiomatic place is a route loader: TanStack Router runs it before the route renders, and the component reads the result with Route.useLoaderData().
apps/web/src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router';

import { getGreeting } from '@/server/greeting';

export const Route = createFileRoute('/')({
  loader: () => getGreeting(),
  component: Home,
});

function Home() {
  const { message } = Route.useLoaderData();

  return <h1>{message}</h1>;
}
The loader calls the server function; from the browser that’s a request to the Start server, which runs the handler and returns the value. Route.useLoaderData() reads it back, typed.

Inputs and validation

Pass data with .inputValidator(). It parses the input on the server before the handler runs — give it a Zod schema and you get a validated, typed data argument:
apps/web/src/server/notes.ts
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';

export const createNote = createServerFn({ method: 'POST' })
  .inputValidator(z.object({ text: z.string().min(1) }))
  .handler(async ({ data }) => {
    // data.text is validated and typed
    return { id: crypto.randomUUID(), text: data.text };
  });
Call it with the argument the validator expects:
const note = await createNote({ data: { text: 'first note' } });
For reads that take parameters, do the same with method: 'GET' and read data in the handler.

When to use what

Server functions are the web-only data layer. In a full-stack project you have a different, richer option.

Server functions — web-only

No apps/api. Backend logic runs on the Start server as createServerFn handlers, called from loaders and components. Reach for server-only deps directly in the handler.

oRPC client — full-stack

There’s an apps/api. The web app talks to it through a fully typed oRPC client and the useApiQuery / useApiMutation / useApiForm hooks the Auth plugin adds.
You scaffoldedData layerImport from
TanStack Start web-onlyServer functions@tanstack/react-start
PostgreSQL + TanStack StartoRPC typed client@/services/api-client.service (Auth plugin)
Both shapes start from the same apps/web base — a landing page plus the getGreeting example. The difference is where data comes from. If you later add an API, you move data fetching from server functions to the oRPC client; the routing, components and TanStack Query setup are unchanged.

Next

  • Overview — the apps/web stack and the two data-fetching shapes.
  • Routing — loaders, params and guards in depth.
  • How Ship works — the resource-owns-everything model and end-to-end type flow.