Server actions
Write type-safe server actions with next-safe-action and Zod, call them from client components, handle errors, and cache data with TanStack Query.
Launch Now runs mutations and most data reads through next-safe-action. Each action declares a Zod input schema, runs behind an authentication middleware, and returns a typed result that the client can read without try/catch. For client-side caching and refetching, the boilerplate also sets up TanStack Query.
This guide shows how to write and call actions. For the list of every action shipped with the boilerplate, see the server actions reference.
How it works
| File | Role |
|---|---|
lib/safe-action.ts | The action clients (action, authAction, nonDemoAction) and the ActionError class. |
lib/*-actions.ts | Action files grouped by feature: account-actions.ts, categories-actions.ts, projects-actions.ts, organization-actions.ts… |
lib/utils/validation-errors.ts | applyValidationErrors(): maps server validation errors onto a react-hook-form form. |
lib/query.tsx | The TanStack Query client and QueryProvider. |
lib/providers.tsx | All client providers, mounted once in app/layout.tsx. |
Action clients
lib/safe-action.ts exports three clients. Pick the most restrictive one that fits.
| Client | Use it for | Context |
|---|---|---|
action | Public actions that don't need a session, such as the newsletter sign-up form. | none |
authAction | Anything that needs a signed-in user. This is the default. | ctx.user |
nonDemoAction | Destructive or sensitive actions that the shared demo account must not run. | ctx.user |
export class ActionError extends Error {
constructor(message: string) {
super(message)
this.name = "SafeActionError"
}
}
export const action = createSafeActionClient({
handleServerError(error) {
if (error instanceof ActionError) {
return error.message
}
console.error("Unexpected error:", error)
return "Unexpected error occured"
},
})
export const authAction = action.use(async ({ next }) => {
const user = await getUser()
if (!user) {
throw new ActionError("You need to be connected !")
}
return next({ ctx: { user } })
})
/** Same as authAction, but refuses destructive actions for the shared demo account. */
export const nonDemoAction = authAction.use(async ({ next, ctx }) => {
if (isDemoUser(ctx.user)) {
throw new ActionError("This action is disabled on the demo account")
}
return next({ ctx })
})authAction loads the user with getUser() from lib/auth-server.ts, which reads the Better Auth session
(deduplicated per request) and calls Next.js unauthorized() when there is none. nonDemoAction compares
the user's email with DEMO_EMAIL; when that variable is unset, it behaves exactly like authAction.
There is no global admin client. Admin actions check the role themselves, either inline:
const listFeedback = authAction.action(async ({ ctx }) => {
if (ctx.user.role !== "admin") throw new ActionError("Not authorized")
return db.query.feedback.findMany({
orderBy: desc(feedback.createdAt),
})
})or with a local middleware, as in lib/notification-actions.ts:
const adminAction = authAction.use(async ({ next, ctx }) => {
if (ctx.user.role !== "admin") {
throw new ActionError("Only platform admins can do this")
}
return next({ ctx })
})Organization actions call requireMembership(organizationId, userId, allowedRoles?) from
lib/organization-server.ts, which throws an ActionError when the user is not a member or lacks one of
the allowed roles.
Write an action
Create or open an action file
Action files live in lib/ and start with "use server". Group actions by feature, for example
lib/bookmarks-actions.ts.
Declare the input schema and the handler
Chain .inputSchema() with a Zod schema, then .action() with the handler. The handler receives the
validated parsedInput and the middleware ctx.
"use server"
import { categories } from "@/drizzle/auth-schema"
import { db } from "@/drizzle/db"
import { authAction } from "@/lib/safe-action"
import { revalidatePath } from "next/cache"
import { randomUUID } from "node:crypto"
import { z } from "zod"
const createCategory = authAction
.inputSchema(
z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
})
)
.action(async ({ ctx, parsedInput }) => {
await db.insert(categories).values({
id: randomUUID(),
userId: ctx.user.id,
name: parsedInput.name,
description: parsedInput.description,
})
revalidatePath("/categories")
})
export { createCategory }Actions without input skip .inputSchema():
export const getProfile = authAction.action(async ({ ctx }) => {
return ctx.user
})Scope every query to the caller
Never trust an id coming from the client. Filter reads and writes by ctx.user.id (or by organization after
requireMembership) and throw when nothing matches:
const deleteProject = authAction
.inputSchema(z.object({ id: z.string() }))
.action(async ({ ctx, parsedInput }) => {
const deleted = await db
.delete(projects)
.where(
and(eq(projects.id, parsedInput.id), eq(projects.userId, ctx.user.id))
)
.returning({ id: projects.id })
if (deleted.length === 0) {
throw new ActionError("Project not found")
}
revalidatePath("/projects")
})Errors
An action call never throws on the client. It resolves to a result object with up to three keys:
| Key | When it is set |
|---|---|
data | The handler returned a value. |
validationErrors | The input failed the Zod schema. Uses next-safe-action's formatted shape: _errors arrays per field. |
serverError | The handler threw. |
handleServerError decides what serverError contains:
- Throw an
ActionErrorfor messages meant for the user ("Project not found", "Not authorized"). The message is sent to the client as is. - Any other error is logged on the server with
console.errorand replaced by the genericUnexpected error occured, so internal details never leak.
Wrap third-party errors you want to show. lib/organization-actions.ts converts Better Auth APIErrors:
async function callAuthApi<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (error) {
if (error instanceof APIError) {
throw new ActionError(error.body?.message ?? error.message)
}
throw error
}
}Revalidation
After a mutation, tell Next.js which server-rendered pages to refresh:
revalidatePath("/categories")refreshes one route.revalidatePath("/admin", "layout")refreshes a layout and every page below it.revalidateOrganization(organizationId)fromlib/organization-server.tsrefreshes/orgsand every page under/orgs/<slug>.
revalidatePath("/projects")
revalidatePath(`/projects/${parsedInput.id}`)Data held in TanStack Query is not affected by revalidatePath. Invalidate those queries on the client (see
TanStack Query below).
Call an action
From a server component
Call the action like an async function and read data:
export default async function RoutePage() {
const result = await listCategories()
const initialCategories = result?.data ?? []
return (
// ...
<CategoriesTable initialCategories={initialCategories} />
)
}From a client component with useAction
useAction from next-safe-action/hooks gives you execute, executeAsync, status ("executing" while
running), isExecuting and callbacks:
import { useAction } from "next-safe-action/hooks"
import { toast } from "@/components/ui/toast"
import { applyValidationErrors } from "@/lib/utils/validation-errors"
const { execute: executeCreate, status: createStatus } = useAction(
createCategory,
{
onSuccess: () => {
toast.add({ title: "Category created", type: "success" })
form.reset()
setOpen(false)
},
onError: ({ error }) => {
if (applyValidationErrors(form.setError, error.validationErrors)) return
toast.add({
title: error.serverError ?? "Failed to create category",
type: "error",
})
},
}
)onError receives both kinds of error. applyValidationErrors() puts field errors under the matching form
inputs and returns true when it set at least one; otherwise the code falls back to a toast with the server
message. Forms are covered in detail in Forms and dialogs.
Use executeAsync when you need to await the result, as the admin "create user" sheet does:
const { executeAsync: uploadAvatar } = useAction(uploadImage)Calling the action directly
Client components can also await an action without the hook. Check serverError yourself:
const result = await listOrgProjects({ organizationId })
if (result?.serverError) throw new Error(result.serverError)
return result?.data ?? []This is the pattern used inside TanStack Query functions.
TanStack Query
Some screens fetch and refetch data on the client: the notification inbox, notification preferences, API keys and resources generated by the resource kit. They use TanStack Query on top of server actions or the Better Auth client.
Setup
lib/query.tsx creates one QueryClient with a five-minute staleTime:
"use client"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
},
},
})
export const QueryProvider = ({ children }: LayoutParams) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}lib/providers.tsx wraps the whole app, and app/layout.tsx renders <Providers> around every page:
export const Providers = ({ children }: LayoutParams) => {
return (
<ThemeProvider>
<TooltipProvider>
<QueryProvider>
<Toaster />
<DialogManagerRenderer />
<CookieConsentProvider>{children}</CookieConsentProvider>
</QueryProvider>
</TooltipProvider>
</ThemeProvider>
)
}Besides the query client, it mounts the theme provider, tooltips, the toast viewport, the dialog manager and the cookie consent provider.
Query an action
Wrap the action in a queryFn, turn serverError into a thrown error, and keep query keys in one place:
const { data, isPending } = useQuery({
queryKey: orgProjectsQueryKey(organizationId),
queryFn: async () => {
const result = await listOrgProjects({ organizationId })
if (result?.serverError) throw new Error(result.serverError)
return (result?.data ?? []) as ResourceRow[]
},
})Mutate and invalidate
Use useMutation and invalidate the affected keys on success. features/account/api-keys/use-api-keys.ts
also calls router.refresh() so server components that depend on the same data (the onboarding checklist)
update too:
export function useToggleApiKeyEnabled() {
const invalidate = useInvalidateApiKeys()
return useMutation({
mutationFn: async (input: { keyId: string; enabled: boolean }) => {
const res = await setApiKeyEnabled(input)
if (res?.serverError) throw new Error(String(res.serverError))
},
onSuccess() {
toast.add({ title: "API key updated", type: "success" })
void invalidate()
},
// ...
})
}For an optimistic update with rollback, see features/notifications/notification-preferences.tsx: it
cancels the query in onMutate, writes the new value with setQueryData, and restores the previous value
in onError.
Good to know: Pick one source of truth per screen. Server-rendered lists refresh with
revalidatePath; lists held in TanStack Query refresh with invalidateQueries. The generated resource
tables use TanStack Query, the categories and projects examples use server props and revalidatePath.