Server Actions
Type-safe server actions with next-safe-action. Authentication middleware, Zod validation, and optimistic updates built-in.
Vibestacks includes next-safe-action for type-safe server actions with built-in authentication middleware and Zod validation.
When to Use Server Actions
Decision Guide
Use Server Actions for internal app operations. Use API Routes for external integrations.
| Use Server Actions | Use API Routes |
|---|---|
| Form submissions | Webhooks (Stripe, etc.) |
| CRUD operations from UI | External API consumers |
| Mutations with optimistic UI | Auth routes (Better Auth) |
| Internal app operations | Public APIs |
Action Clients
Three pre-configured clients are available in lib/safe-action.ts:
// No auth required - for public actions
import { actionClient } from "@/lib/safe-action";
// Requires authenticated user
import { authActionClient } from "@/lib/safe-action";
// Requires active subscription
import { subscribedActionClient } from "@/lib/safe-action";Context Available
Each client provides different context:
| Client | Context (ctx) |
|---|---|
actionClient | None |
authActionClient | { user, session } |
subscribedActionClient | { user, session, subscription } |
Creating Actions
Create the Action File
Create a new file in lib/actions/:
"use server";
import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { projects } from "@/db/schema";
// Define validation schema
const createProjectSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().optional(),
});
// Create the action
export const createProject = authActionClient
.schema(createProjectSchema)
.action(async ({ parsedInput, ctx }) => {
const { name, description } = parsedInput;
const { user } = ctx;
const [project] = await db
.insert(projects)
.values({
name,
description,
userId: user.id,
})
.returning();
revalidatePath("/dashboard/projects");
return { project };
});Use in a Component
"use client";
import { useAction } from "next-safe-action/hooks";
import { createProject } from "@/lib/actions/projects";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function CreateProjectForm() {
const [name, setName] = useState("");
const { execute, status, result } = useAction(createProject, {
onSuccess: ({ data }) => {
setName("");
console.log("Created project:", data?.project);
},
onError: ({ error }) => {
console.error("Failed:", error);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
execute({ name });
}}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
/>
<Button disabled={status === "executing"}>
{status === "executing" ? "Creating..." : "Create Project"}
</Button>
{result.serverError && (
<p className="text-destructive">{result.serverError}</p>
)}
</form>
);
}Validation Errors
Access field-level validation errors from the result:
const { result } = useAction(createProject);
// Server error (thrown exceptions)
result.serverError // string | undefined
// Validation errors (Zod failures)
result.validationErrors?.name?._errors // string[] | undefined
result.validationErrors?.description?._errors // string[] | undefinedDisplay them in your form:
<div>
<Input {...props} />
{result.validationErrors?.name?._errors?.[0] && (
<p className="text-sm text-destructive mt-1">
{result.validationErrors.name._errors[0]}
</p>
)}
</div>Optimistic Updates
For instant UI feedback, update the UI before the server responds:
"use client";
import { useAction } from "next-safe-action/hooks";
import { toggleFavorite } from "@/lib/actions/favorites";
import { useState } from "react";
export function FavoriteButton({ itemId, initialFavorited }) {
const [isFavorited, setIsFavorited] = useState(initialFavorited);
const { execute } = useAction(toggleFavorite, {
onExecute: () => {
// Optimistically update UI immediately
setIsFavorited((prev) => !prev);
},
onError: () => {
// Revert on error
setIsFavorited((prev) => !prev);
},
});
return (
<button onClick={() => execute({ itemId })}>
{isFavorited ? "♥ Favorited" : "♡ Favorite"}
</button>
);
}Subscription Gating
Use subscribedActionClient to require an active subscription:
"use server";
import { z } from "zod";
import { subscribedActionClient } from "@/lib/safe-action";
const exportSchema = z.object({
format: z.enum(["csv", "json", "pdf"]),
});
export const exportData = subscribedActionClient
.schema(exportSchema)
.action(async ({ parsedInput, ctx }) => {
const { format } = parsedInput;
const { user, subscription } = ctx;
// User is guaranteed to have active subscription
console.log("Plan:", subscription?.plan);
// Generate export...
return { downloadUrl: `/api/exports/${user.id}` };
});If a user without an active subscription calls this action, they'll receive an "Subscription required" error.
Error Handling
Throw errors to return them to the client:
.action(async ({ parsedInput, ctx }) => {
const project = await db.query.projects.findFirst({
where: eq(projects.id, parsedInput.id),
});
if (!project) {
throw new Error("Project not found");
}
if (project.userId !== ctx.user.id) {
throw new Error("You don't have access to this project");
}
// Continue with operation...
});The error message will be available in result.serverError.
Cache Revalidation
After mutations, revalidate affected pages:
import { revalidatePath, revalidateTag } from "next/cache";
// Revalidate a specific path
revalidatePath("/dashboard/projects");
// Revalidate all paths with a tag
revalidateTag("projects");File Organization
Organize actions by domain:
lib/actions/
├── projects.ts # Project CRUD
├── teams.ts # Team management
├── settings.ts # User settings
├── billing.ts # Subscription changes
└── example.ts # Reference examplesExample Actions
Vibestacks includes example actions in lib/actions/example.ts:
submitContactForm- Public action (no auth)updateProfile- Authenticated actionexportUserData- Subscription-gated actiontoggleFavorite- Optimistic update pattern
Use these as templates for your own actions.
useAction Hook Options
The useAction hook accepts these callbacks:
const { execute, status, result } = useAction(myAction, {
// Called immediately when execute() is called
onExecute: () => {},
// Called when action succeeds
onSuccess: ({ data, input }) => {},
// Called when action fails
onError: ({ error, input }) => {},
// Called after success or error
onSettled: ({ result, input }) => {},
});Status Values
The status value indicates the current state:
| Status | Description |
|---|---|
"idle" | Initial state, no execution yet |
"executing" | Action is running |
"hasSucceeded" | Last execution succeeded |
"hasErrored" | Last execution failed |
Use it for loading states:
<Button disabled={status === "executing"}>
{status === "executing" ? "Saving..." : "Save"}
</Button>