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 handlingerrorMiddleware normalizes thrown errors; router redirect() / 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 check
export 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 check
export 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:

  1. Always validate input — Pass a Zod schema to .validator() for type-safe validation
  2. Use the factoriescreateServerFn({ method: 'POST' }).middleware(authFunctionMiddleware) / createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware) / createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware) handle auth; don't roll your own
  3. Verify permissions — Layer withFeaturePermission or withMinRole via .middleware([...])
  4. Let errors flowerrorMiddleware normalizes thrown errors and lets redirect() / notFound() pass through
  5. Scope to organizations — Every query should include the organization context (context.organizationId or requireActiveOrganizationId())
  6. Keep business logic in services — Functions orchestrate; services own the domain logic
  7. Add logging — Add structured logs for debugging production issues
  8. 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 handling
export 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 it
export 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 tenants
const projects = await db.project.findMany();
// ✅ Correct - scoped to the active organization
const 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.organizationId
export const function = createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware)
.validator(schema)
.handler(async () => {
const organizationId = await getActiveOrganizationId();
});
// ✅ Correct - read it from context
export const function = createServerFn({ method: 'POST' }).middleware(organizationFunctionMiddleware)
.validator(schema)
.handler(async ({ context }) => {
const { organizationId, role } = context;
});

Next: Function Middleware →