Anatomy of a Server Action: Validation, Auth, and Error Handling
Server actions are the bridge between your React forms and your backend logic. Get them wrong and you're debugging mystery errors in production. Here's the pattern we use in every action.
The ActionResult Pattern
Every server action in Bounce Kit returns a typed result:
export interface ActionResult<T = unknown> {
success: boolean
message: string
data?: T
}
No thrown errors. No untyped responses. The client always knows exactly what happened.
The Three-Step Pattern
Step 1: Authenticate
const session = await getSession()
if (!session) {
return { success: false, message: "You must be signed in." }
}
Step 2: Validate with Zod
const parsed = profileSchema.safeParse(values)
if (!parsed.success) {
return {
success: false,
message: parsed.error.issues[0]?.message ?? "Invalid input",
}
}
Always safeParse, never parse. We return the error — we never throw it.
Step 3: Execute and Return
await db.user.update({
where: { id: session.userId },
data: parsed.data,
})
return {
success: true,
message: "Profile updated successfully",
data: parsed.data,
}
Client-Side Integration
"use client"
import { updateProfile } from "@/lib/actions"
import { useToast } from "@/components/feedback/toast-provider"
const { addToast } = useToast()
async function onSubmit(values: ProfileValues) {
const result = await updateProfile(values)
addToast({
title: result.message,
variant: result.success ? "success" : "error",
})
}
The toast system handles UX feedback. The typed result handles logic. Clean separation.
Why This Pattern Wins
- No try/catch spaghetti — errors are values, not exceptions
- Type-safe end-to-end — client knows the shape of every response
- Auth baked in — no action runs without a session check
- Zod validates twice — client-side (React Hook Form) AND server-side
This pattern scales from a single form to a hundred actions without complexity creep.