Loading...
Loading...
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).
npx skill4agent add tanstack/router server-functionscreateServerFnCRITICAL: 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. Routeis UX, not the data boundary. See start-core/auth-server-primitives 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 insidebeforeLoad, NOT in loaders directly. CRITICAL: Do not usecreateServerFndirectives,"use server", or any Next.js/Remix server patterns. TanStack Start usesgetServerSidePropsexclusively.createServerFn
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 }
})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>
)
}useServerFnimport { 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>
)
}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
}const greetUser = createServerFn({ method: 'GET' })
.validator((data: { name: string }) => data)
.handler(async ({ data }) => {
return `Hello, ${data.name}!`
})
await greetUser({ data: { name: 'John' } })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}`
})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 }
})const riskyFunction = createServerFn().handler(async () => {
throw new Error('Something went wrong!')
})
try {
await riskyFunction()
} catch (error) {
console.log(error.message) // "Something went wrong!"
}import { redirect } from '@tanstack/react-router'
const requireAuth = createServerFn().handler(async () => {
const user = await getCurrentUser()
if (!user) {
throw redirect({ to: '/login' })
}
return user
})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
})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()
})getRequest()getRequestHeader(name)setResponseHeader(name, value)setResponseHeaders(headers)setResponseStatus(code)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)// 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) })
}// 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)
})beforeLoadcreateServerFn// 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 } })
})authMiddlewarecreateServerFn// 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(),
})src/pages/app/layout.tsx_app/react-router-domnext/src/routes/createFileRoutecreateServerFn// 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' })
.handler(async () => { ... })
export const Route = createFileRoute('/users/$id')({
loader: ({ params }) => getUser({ data: { id: params.id } }),
component: UserPage,
})src/pages/app/layout.tsxreact-router-dom// WRONG — can cause bundler issues
const { getUser } = await import('~/utils/users.functions')
// CORRECT — static imports are safe, build handles environment shaking
import { getUser } from '~/utils/users.functions'createServerFn()// WRONG — getItems is a function, not a Promise
const data = await getItems
// CORRECT — call the function
const data = await getItems()
// With validated input
const data = await getItems({ data: { id: '1' } })Cache-Control: publicCache-Control: public, max-age=N// WRONG — auth'd response, public cache, leaks to next user via CDN
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
const session = await requireSession() // identity-dependent
setResponseHeaders({ 'Cache-Control': 'public, max-age=300' })
return db.orders.findMany({ where: { userId: session.userId } })
})
// CORRECT — private + Vary so any cache that does store it keys by identity
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
const session = await requireSession()
setResponseHeaders({
'Cache-Control': 'private, max-age=60',
Vary: 'Cookie, Authorization',
})
return db.orders.findMany({ where: { userId: session.userId } })
})
// ALSO CORRECT — opt out entirely for sensitive data
setResponseHeaders({ 'Cache-Control': 'no-store' })publicprivateno-storepublicuseServerFnuseServerFnthrow redirect()throw notFound()useMutationuseQuery// Plain data — direct call is fine (also fine to pass to useMutation/useQuery)
<button onClick={() => deletePost({ data: { id } })}>Delete</button>
useMutation({ mutationFn: deletePost })
// Throws redirect/notFound — MUST wrap with useServerFn so the router handles the throw
const signupFn = useServerFn(signup) // signup throws redirect on success
<button onClick={() => signupFn({ data: form })}>Sign up</button>useServerFn// WRONG — this loader also runs during SSR, where a relative URL may fail
export const Route = createFileRoute('/issues')({
loader: () => fetch('/api/issues').then((response) => response.json()),
})
// CORRECT — call the server function; the client build gets an RPC stub
export const Route = createFileRoute('/issues')({
loader: () => listIssues(),
})fetchgetCurrentUseruseSession