Skip to main content

server-functions

createServerFn (GET/POST), validator (Zod or function), useServerFn hook, server context utilities (getRequest, getRequestHeader, setResponseHeader, setResponseStatus), error handling (throw errors, redirect, notFound), streaming, FormData handling, file organization (.functions.ts, .server.ts).

Source facts

Repository
TanStack/router
Last source activity
October 12, 2026 at 22:01
Detected SKILL.md language
English
Stars
15,202
Forks
1,900

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
server-functions
description
createServerFn (GET/POST), validator (Zod or function), useServerFn hook, server context utilities (getRequest, getRequestHeader, setResponseHeader, setResponseStatus), error handling (throw errors, redirect, notFound), streaming, FormData handling, file organization (.functions.ts, .server.ts).
metadata
{"type":"sub-skill","library":"tanstack-start","library_version":"1.170.14"}
requires
["start-core"]
sources
["TanStack/router:docs/start/framework/react/guide/server-functions.md"]
# Server Functions Server functions are type-safe RPCs created with `createServerFn`. They run exclusively on the server but can be called from anywhere — loaders, components, hooks, event handlers, or other server functions. > **CRITICAL**: Server functions are API endpoints. They are reachable independently of whichever route renders the calling UI. **Auth must be enforced inside the handler (or via middleware) for any server function that touches private data.** Route `beforeLoad` is UX, not the data boundary. See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) for the session/middleware pattern. > **CRITICAL**: Loaders are ISOMORPHIC — they run on BOTH client and server. Database queries, file system access, and secret API keys MUST go inside `createServerFn`, NOT in loaders directly. > **CRITICAL**: Do not use `"use server"` directives, `getServerSideProps`, or any Next.js/Remix server patterns. TanStack Start uses `createServerFn` exclusively. ## Basic Usage ```tsx import { createServerFn } from '@tanstack/react-start' // GET (default) const getData = createServerFn().handler(async () => { return { message: 'Hello from server!' } }) // POST const saveData = createServerFn({ method: 'POST' }).handler(async () => { return { success: true } }) ``` ## Calling from Loaders ```tsx import { createFileRoute } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' const getPosts = createServerFn({ method: 'GET' }).handler(async () => { const posts = await db.query('SELECT * FROM posts') return { posts } }) export const Route = createFileRoute('/posts')({ loader: () => getPosts(), component: PostList, }) function PostList() { const { posts } = Route.useLoaderData() return ( <ul> {posts.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> ) } ``` ## Calling from Components Use the `useServerFn` hook to call server functions from event handlers: ```tsx import { useServerFn } from '@tanstack/react-start' const deletePost = createServerFn({ method: 'POST' }) .validator((data: { id: string }) => data) .handler(async ({ data }) => { await db.delete('posts').where({ id: data.id }) return { success: true } }) function DeleteButton({ postId }: { postId: string }) { const deletePostFn = useServerFn(deletePost) return ( <button onClick={() => deletePostFn({ data: { id: postId } })}> Delete </button> ) } ``` ## Cache-Coherent Mutations Keep reads and writes behind server functions that use the same authoritative store. Await the write, then invalidate the route cache so its loader reads the persisted result: ```tsx const listIssues = createServerFn({ method: 'GET' }).handler(() => { return db.issues.findMany() }) const createIssue = createServerFn({ method: 'POST' }) .validator((data: unknown) => { if ( typeof data !== 'object' || data === null || !('title' in data) || typeof data.title !== 'string' || data.title.trim().length === 0 ) { throw new Error('Title is required') } return { title: data.title.trim() } }) .handler(async ({ data }) => { return db.issues.create({ data }) }) export const Route = createFileRoute('/issues')({ loader: () => listIssues(), component: IssuesPage, }) function IssuesPage() { const router = useRouter() const createIssueFn = useServerFn(createIssue) const handleCreate = async (title: string) => { await createIssueFn({ data: { title } }) await router.invalidate({ sync: true }) } // render Route.useLoaderData() and call handleCreate from the form } ``` Do not update only local component state after a persistent mutation. Verify the rendered list after create, update, delete, and a fresh page load. ## Input Validation ### Basic Validator ```tsx const greetUser = createServerFn({ method: 'GET' }) .validator((data: { name: string }) => data) .handler(async ({ data }) => { return `Hello, ${data.name}!` }) await greetUser({ data: { name: 'John' } }) ``` ### Zod Validator ```tsx import { z } from 'zod' const createUser = createServerFn({ method: 'POST' }) .validator( z.object({ name: z.string().min(1), age: z.number().min(0), }), ) .handler(async ({ data }) => { return `Created user: ${data.name}, age ${data.age}` }) ``` Input validation does not validate output serialization. When a response schema changes, update the database selection, service return value, server-function result, loader consumer, and UI. Add a runtime test that calls the handler or HTTP boundary and asserts the new field in the returned payload. ### FormData ```tsx const submitForm = createServerFn({ method: 'POST' }) .validator((data) => { if (!(data instanceof FormData)) { throw new Error('Expected FormData') } return { name: data.get('name')?.toString() || '', email: data.get('email')?.toString() || '', } }) .handler(async ({ data }) => { return { success: true } }) ``` ## Error Handling ### Errors ```tsx const riskyFunction = createServerFn().handler(async () => { throw new Error('Something went wrong!') }) try { await riskyFunction() } catch (error) { console.log(error.message) // "Something went wrong!" } ``` ### Redirects ```tsx import { redirect } from '@tanstack/react-router' const requireAuth = createServerFn().handler(async () => { const user = await getCurrentUser() if (!user) { throw redirect({ to: '/login' }) } return user }) ``` ### Not Found ```tsx import { notFound } from '@tanstack/react-router' const getPost = createServerFn() .validator((data: { id: string }) => data) .handler(async ({ data }) => { const post = await db.findPost(data.id) if (!post) { throw notFound() } return post }) ``` ## Server Context Utilities Access request/response details inside server function handlers: ```tsx import { createServerFn } from '@tanstack/react-start' import { getRequest, getRequestHeader, setResponseHeaders, setResponseStatus, } from '@tanstack/react-start/server' // Public, non-personalized data — safe to cache shared across users. const getPublicData = createServerFn({ method: 'GET' }).handler(async () => { setResponseHeaders({ // 'public' is correct ONLY when the response does not depend on identity. // For anything tied to a session/user/tenant, use 'private' or 'no-store'. 'Cache-Control': 'public, max-age=300', }) setResponseStatus(200) return fetchPublicData() }) // Authenticated data — must NOT be 'public'. const getMyData = createServerFn({ method: 'GET' }).handler(async () => { const authHeader = getRequestHeader('Authorization') // ... auth check ... setResponseHeaders({ // 'private' = only the user-agent may cache. Vary by Cookie/Authorization // so any intermediary that does cache keys by identity, not URL alone. 'Cache-Control': 'private, max-age=60', Vary: 'Cookie, Authorization', }) return fetchPersonalizedData() }) ``` Available utilities: - `getRequest()` — full Request object - `getRequestHeader(name)` — single request header - `setResponseHeader(name, value)` — single response header - `setResponseHeaders(headers)` — multiple response headers - `setResponseStatus(code)` — HTTP status code ## File Organization ```text src/utils/ ├── users.functions.ts # createServerFn wrappers (safe to import anywhere) ├── users.server.ts # Server-only helpers (DB queries, internal logic) └── schemas.ts # Shared validation schemas (client-safe) ``` ```tsx // users.server.ts — server-only helpers import { db } from '~/db' export async function findUserById(id: string) { return db.query.users.findFirst({ where: eq(users.id, id) }) } ``` ```tsx // users.functions.ts — server functions import { createServerFn } from '@tanstack/react-start' import { findUserById } from './users.server' export const getUser = createServerFn({ method: 'GET' }) .validator((data: { id: string }) => data) .handler(async ({ data }) => { return findUserById(data.id) }) ``` Static imports of server functions are safe — the build replaces implementations with RPC stubs in client bundles. ## Common Mistakes ### 1. CRITICAL: Relying on a route guard to protect a server function A `beforeLoad` redirect protects the **route's UI**, not the **data endpoint**. `createServerFn` exposes a callable endpoint that an attacker can hit directly — no need to load the route at all. Auth on the endpoint is the security boundary; auth on the route is UX. ```tsx // WRONG — the route guard doesn't reach the handler const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => { return db.orders.findMany() // ← anyone can call the RPC }) export const Route = createFileRoute('/_authenticated/orders')({ beforeLoad: ({ context }) => { if (!context.auth.isAuthenticated) throw redirect({ to: '/login' }) }, loader: () => getMyOrders(), }) // CORRECT — auth enforced on the handler itself const getMyOrders = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .handler(async ({ context }) => { return db.orders.findMany({ where: { userId: context.session.userId } }) }) ``` Apply `authMiddleware` (or an equivalent in-handler check) to **every** `createServerFn` that needs auth. See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) for the full session/middleware pattern and [start-core/middleware](../middleware/SKILL.md) for composing the factory. ### 2. CRITICAL: Putting server-only code in loaders ```tsx // WRONG — loader is ISOMORPHIC, runs on BOTH client and server export const Route = createFileRoute('/posts')({ loader: async () => { const posts = await db.query('SELECT * FROM posts') return { posts } }, }) // CORRECT — use createServerFn for server-only logic const getPosts = createServerFn({ method: 'GET' }).handler(async () => { const posts = await db.query('SELECT * FROM posts') return { posts } }) export const Route = createFileRoute('/posts')({ loader: () => getPosts(), }) ``` ### 3. CRITICAL: Using Next.js / Remix / React Router DOM patterns If the file lives at `src/pages/`, `app/layout.tsx`, `_app/`, or imports anything from `react-router-dom` or `next/`, it is wrong-framework code. TanStack Start uses `src/routes/` + `createFileRoute` + `createServerFn`. ```tsx // WRONG — "use server" is a React directive, not used in TanStack Start 'use server' export async function getUser() { ... } // WRONG — getServerSideProps is Next.js Pages Router export async function getServerSideProps() { ... } // WRONG — Next.js App Router server component data fetching export default async function Page() { const data = await fetch(...).then(r => r.json()) return <div>{data}</div> } // WRONG — Remix export async function loader({ request }) { ... } export async function action({ request }) { ... } // WRONG — react-router-dom (a different library) import { Link, useNavigate } from 'react-router-dom' // CORRECT — TanStack Start import { createServerFn } from '@tanstack/react-start' import { Link, useNavigate, createFileRoute } from '@tanstack/react-router' const getUser = createServerFn({ method: 'GET' })
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub