Launch NowDocs

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

FileRole
lib/safe-action.tsThe action clients (action, authAction, nonDemoAction) and the ActionError class.
lib/*-actions.tsAction files grouped by feature: account-actions.ts, categories-actions.ts, projects-actions.ts, organization-actions.ts
lib/utils/validation-errors.tsapplyValidationErrors(): maps server validation errors onto a react-hook-form form.
lib/query.tsxThe TanStack Query client and QueryProvider.
lib/providers.tsxAll client providers, mounted once in app/layout.tsx.

Action clients

lib/safe-action.ts exports three clients. Pick the most restrictive one that fits.

ClientUse it forContext
actionPublic actions that don't need a session, such as the newsletter sign-up form.none
authActionAnything that needs a signed-in user. This is the default.ctx.user
nonDemoActionDestructive or sensitive actions that the shared demo account must not run.ctx.user
lib/safe-action.ts
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:

lib/feedback-actions.ts
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:

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.

lib/categories-actions.ts
"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():

lib/actions.ts
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:

lib/projects-actions.ts
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:

KeyWhen it is set
dataThe handler returned a value.
validationErrorsThe input failed the Zod schema. Uses next-safe-action's formatted shape: _errors arrays per field.
serverErrorThe handler threw.

handleServerError decides what serverError contains:

  • Throw an ActionError for 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.error and replaced by the generic Unexpected error occured, so internal details never leak.

Wrap third-party errors you want to show. lib/organization-actions.ts converts Better Auth APIErrors:

lib/organization-actions.ts
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) from lib/organization-server.ts refreshes /orgs and every page under /orgs/<slug>.
lib/projects-actions.ts
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:

app/(app)/categories/page.tsx
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:

features/categories/category-form-sheet.tsx
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:

features/admin/create-user-sheet.tsx
const { executeAsync: uploadAvatar } = useAction(uploadImage)

Calling the action directly

Client components can also await an action without the hook. Check serverError yourself:

features/org-projects/org-project-table.tsx
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:

lib/query.tsx
"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:

lib/providers.tsx
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:

features/org-projects/org-project-table.tsx
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:

features/account/api-keys/use-api-keys.ts
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.

Next steps