Server Functions
Build type-safe server functions for handling user mutations with authentication and validation in your TanStack Start Prisma SaaS application.
Server functions are the backbone of data mutations in your application. They run on the server, have full access to your database, and can be called directly from client components without hand-writing API endpoints.
Note: In TanStack Start, mutations are server functions created with
createServerFn. This repo pre-binds authorization middleware to that builder through middleware tuples in@kit/function-middleware(authFunctionMiddleware,organizationFunctionMiddleware,adminFunctionMiddleware,withAdminPermission).
What are Server Functions?
Server functions are async functions that execute on the server when invoked from the client. They're ideal for form submissions, data mutations, and any operation requiring server-side logic. Key benefits include:
- Type safety — Full TypeScript support from client to server, including the validated input and the middleware-injected
context - Automatic serialization — Arguments and return values are serialized automatically
- Built-in error handling —
errorMiddlewarenormalizes thrown errors; routerredirect()/notFound()pass through - Session management — Access the authenticated user, organization, and role through middleware-injected
context
Basic Server Function
The lowest-level primitive is createServerFn from @tanstack/react-start. Mutations use { method: 'POST' }:
apps/web/src/lib/example/my.functions.ts
import { createServerFn } from '@tanstack/react-start';import { getSession } from '@kit/better-auth/context';export const myFunction = createServerFn({ method: 'POST' }) .validator((data: { name: string }) => data) .handler(async ({ data }) => { const result = await getSession(); if (!result) { throw new Error('Unauthorized'); } // Perform operation return { success: true }; });In practice you rarely check auth by hand — you start from a middleware tuple in @kit/function-middleware that injects the authenticated context for you.
With Authentication and Validation
Never trust client input. Pass a Zod schema to .validator() to validate data before the handler runs. authFunctionMiddleware handles authentication automatically, injecting context.user and context.session, so you can focus on business logic:
apps/web/src/lib/example/create-user.functions.ts
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { db } from '@kit/database';const CreateUserSchema = z.object({ name: z.string().min(3), email: z.email(),});export const createUserFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .validator(CreateUserSchema) .handler(async ({ data, context }) => { // context.user available from authMiddleware // data already validated by the schema const newUser = await db.user.create({ data }); return newUser; });createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) returns a standard createServerFn builder, so you continue the chain with .validator(...), optionally .middleware([...]), and .handler(...).
A Real Function
The kit's organization name update is a complete, real example. It delegates the write to a service and reads the active organization from the request context:
packages/organization/ui/src/lib/server/functions/update-organization-name.functions.ts
import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { requireActiveOrganizationId } from '@kit/better-auth/context';import { UpdateOrganizationNameSchema } from '@kit/organization-core/schemas/update-organization-name.schema';import { createUpdateOrganizationService } from '@kit/organization-core/services';/** * Update the active organization's name using Better Auth */export const updateOrganizationNameFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .validator(UpdateOrganizationNameSchema) .handler(async ({ data }) => { const organizationId = await requireActiveOrganizationId(); const service = createUpdateOrganizationService(); await service.updateOrganization({ organizationId, name: data.name, }); });Error Handling
Every tuple includes errorMiddleware, which routes thrown errors through handleAuthError — safe Better Auth error codes are preserved, everything else is masked. Router control-flow signals (redirect() / notFound()) pass through untouched, so you generally just throw and let the middleware do the right thing:
import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';export const myFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .validator(MySchema) .handler(async ({ data }) => { await someOperation(data); // Throwing here is normalized by errorMiddleware. // redirect()/notFound() (from @tanstack/react-router) pass through. return { success: true }; });Avoid wrapping the whole handler in a try/catch that swallows errors — that defeats errorMiddleware and (in callers) breaks redirect() navigation.
Redirecting
Redirect from a route loader or beforeLoad using redirect() from @tanstack/react-router. For post-mutation navigation, prefer navigating on the client from the mutation's success callback (see Working with Forms):
import { redirect } from '@tanstack/react-router';export const Route = createFileRoute('/_authenticated/projects/$id')({ loader: async ({ params }) => { const project = await fetchProject({ data: params.id }); if (!project) { throw redirect({ to: '/projects' }); } return { project }; },});Organization-Scoped Functions
In a multi-tenant application, every operation must be scoped to the current organization. organizationFunctionMiddleware injects context.organizationId and context.role and guarantees the user is a member of the active organization:
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';import { db } from '@kit/database';export const listProjectsFunction = createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware) .validator(z.object({})) .handler(async ({ context }) => { // context.organizationId and context.role available from middleware // User is verified as a member of the organization return db.project.findMany({ where: { organizationId: context.organizationId }, }); });Permission Checks
Authorization should be declarative. Layer extra middleware with the native .middleware([...]) step. Use withFeaturePermission for resource-based checks or withMinRole for role-hierarchy checks:
import { z } from 'zod';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { withFeaturePermission, withMinRole } from '@kit/function-middleware/server';import { db } from '@kit/database';const DeleteProjectSchema = z.object({ projectId: z.string() });// Using a resource:function permission checkexport const deleteProjectFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .middleware([withFeaturePermission({ project: ['delete'] })]) .validator(DeleteProjectSchema) .handler(async ({ data, context }) => { // Permission already verified by middleware; context.organizationId is set await db.project.delete({ where: { id: data.projectId, organizationId: context.organizationId, }, }); });// Or using a minimum role checkexport const adminOnlyFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .middleware([withMinRole('admin')]) .validator(z.object({})) .handler(async ({ context }) => { // Only admins and owners reach here; context.role is guaranteed });Admin Functions
Super-admin operations use createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware) (admin role required) or withAdminPermission(requirements), which adds an admin RBAC permission check:
packages/admin/src/users/lib/functions/user-admin.functions.ts
import { createServerFn } from '@tanstack/react-start';import { adminFunctionMiddleware } from '@kit/function-middleware/functions';import { withAdminPermission } from '@kit/function-middleware/server';import { banUserSchema } from '../schemas';import { createUserAdminService } from '../services/user-admin.service';const service = createUserAdminService();export const banUserFunction = createServerFn({ method: 'POST' }).middleware([ ...adminFunctionMiddleware, withAdminPermission({ user: ['ban'] }), ]) .validator(banUserSchema) .handler(async ({ data, context }) => { return service.banUser({ adminId: context.user.id, ...data, }); });Logging in Server Functions
Adding structured logging helps debug production issues. Use the logger service to track execution:
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { authFunctionMiddleware } from '@kit/function-middleware/functions';import { getLogger } from '@kit/shared/logger';import { generateId } from '@kit/shared/uuid';import { db } from '@kit/database';const CreateProjectSchema = z.object({ name: z.string() });export const createProjectFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .validator(CreateProjectSchema) .handler(async ({ data, context }) => { const logger = (await getLogger()).child({ name: 'projects.create', userId: context.user.id, }); logger.info('Creating project...'); try { const created = await db.project.create({ data: { id: generateId(), name: data.name }, }); logger.info({ projectId: created.id }, 'Project created'); return { project: created }; } catch (error) { logger.error({ error }, 'Failed to create project'); throw error; } });Best Practices
Follow these guidelines for robust server functions:
- Always validate input — Pass a Zod schema to
.validator()for type-safe validation - Use the factories —
createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware)/createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware)/createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware)handle auth; don't roll your own - Verify permissions — Layer
withFeaturePermissionorwithMinRolevia.middleware([...]) - Let errors flow —
errorMiddlewarenormalizes thrown errors and letsredirect()/notFound()pass through - Scope to organizations — Every query should include the organization context (
context.organizationIdorrequireActiveOrganizationId()) - Keep business logic in services — Functions orchestrate; services own the domain logic
- Add logging — Add structured logs for debugging production issues
- Refresh loader data on the client — Server functions don't revalidate loaders (no
revalidatePath). After a mutation,await router.invalidate()in the caller's success path — see Revalidating After a Mutation
Common Mistakes
Swallowing errors with try/catch
// ❌ Wrong - masks the error and breaks errorMiddleware/redirect handlingexport const myFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .validator(MySchema) .handler(async ({ data }) => { try { await doWork(data); } catch { return { success: false }; } });// ✅ Correct - throw and let errorMiddleware normalize itexport const myFunction = createServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) .validator(MySchema) .handler(async ({ data }) => { await doWork(data); return { success: true }; });Missing organization scope
// ❌ Wrong - returns all projects across tenantsconst projects = await db.project.findMany();// ✅ Correct - scoped to the active organizationconst organizationId = await requireActiveOrganizationId();const projects = await db.project.findMany({ where: { organizationId },});Re-fetching context the middleware already provides
// ❌ Wrong - createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware) already injects context.organizationIdexport const function = createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware) .validator(schema) .handler(async () => { const organizationId = await getActiveOrganizationId(); });// ✅ Correct - read it from contextexport const function = createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware) .validator(schema) .handler(async ({ context }) => { const { organizationId, role } = context; });Next: Function Middleware →