nextjs-server-actions
Next.js with ZSA Server Actions
Build type-safe, validated server actions in Next.js with Zod.
Installation
npm install zsa zsa-react zod
# Optional: for React Query integration
npm install zsa-react-query @tanstack/react-query
Basic Server Action
// actions/user.ts
"use server";
import { createServerAction } from "zsa";
import z from "zod";
export const createUserAction = createServerAction()
.input(
z.object({
email: z.string().email(),
name: z.string().min(2),
})
)
.handler(async ({ input }) => {
// Input is fully typed and validated
const user = await db.user.create({
data: { email: input.email, name: input.name },
});
return user;
});
Calling Server Actions
From Server (no try/catch needed)
const [data, err] = await createUserAction({ email: "john@example.com", name: "John Doe" });
if (err) console.error(err.code, err.message);
From Client with useServerAction
"use client";
import { useServerAction } from "zsa-react";
import { createUserAction } from "./actions/user";
export function CreateUserForm() {
const { isPending, execute, data, error, isError, isSuccess, reset } =
useServerAction(createUserAction);
const handleSubmit = async (formData: FormData) => {
const [data, err] = await execute({
email: formData.get("email") as string,
name: formData.get("name") as string,
});
if (err) {
// Error handling
return;
}
// Success handling
};
return (
<form action={handleSubmit}>
<input name="email" type="email" disabled={isPending} />
<input name="name" type="text" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
{isError && <p className="error">{error.message}</p>}
{isSuccess && <p className="success">User created: {data.name}</p>}
</form>
);
}
Input & Output Validation
"use server";
import { createServerAction } from "zsa";
import z from "zod";
// Input schema
const createPostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
tags: z.array(z.string()).optional(),
});
// Output schema
const postOutputSchema = z.object({
id: z.string(),
title: z.string(),
createdAt: z.date(),
});
export const createPostAction = createServerAction()
.input(createPostSchema)
.output(postOutputSchema) // Validates return value
.handler(async ({ input }) => {
const post = await db.post.create({ data: input });
return {
id: post.id,
title: post.title,
createdAt: post.createdAt,
};
});
FormData Input
"use server";
export const submitContactForm = createServerAction()
.input(z.object({ name: z.string().min(2), email: z.string().email() }),
{ type: "formData" })
.handler(async ({ input }) => {
await sendEmail(input);
return { success: true };
});
Procedures (Authentication & Authorization)
Create reusable middleware for auth, roles, and permissions:
// lib/procedures.ts
"use server";
// Authentication procedure
export const authedProcedure = createServerActionProcedure().handler(async () => {
const session = await auth();
if (!session?.user) throw new Error("Not authenticated");
return { user: { id: session.user.id, email: session.user.email, role: session.user.role } };
});
// Admin procedure (chains from authedProcedure)
export const adminProcedure = createServerActionProcedure(authedProcedure)
.handler(async ({ ctx }) => {
if (ctx.user.role !== "admin") throw new Error("Admin access required");
return ctx;
});
Usage:
// Protected action
export const createPost = authedProcedure
.createServerAction()
.input(z.object({ title: z.string() }))
.handler(async ({ input, ctx }) => {
return db.post.create({ data: { ...input, authorId: ctx.user.id } });
});
// Public action (no procedure)
export const publicAction = createServerAction()
.input(schema)
.handler(async ({ input }) => { /* ... */ });
Callbacks
"use server";
import { createServerAction } from "zsa";
import z from "zod";
export const createOrderAction = createServerAction()
.input(z.object({ productId: z.string(), quantity: z.number() }))
.onStart(async () => {
console.log("Order creation started");
})
.onSuccess(async ({ input, data }) => {
// Send confirmation email
await sendOrderConfirmation(data.id);
})
.onError(async ({ err }) => {
// Log error to monitoring service
await logError(err);
})
.onComplete(async () => {
console.log("Order action completed");
})
.handler(async ({ input }) => {
return db.order.create({ data: input });
});
Error Handling
Error Codes: INPUT_PARSE_ERROR, OUTPUT_PARSE_ERROR, ERROR, NOT_AUTHORIZED, TIMEOUT, INTERNAL_SERVER_ERROR
const [result, err] = await execute({ /* ... */ });
if (err) {
switch (err.code) {
case "INPUT_PARSE_ERROR":
console.log(err.fieldErrors); // { email: ["Invalid email"] }
break;
case "NOT_AUTHORIZED":
router.push("/login");
break;
default:
toast.error(err.message);
}
return;
}
// Success - use result
Server-side:
.handler(async ({ input, ctx }) => {
const result = await Service.create(ctx.userId, input);
if (!result.success) throw new Error(result.error);
return result.data;
})
useServerAction Options
const {
data,
isPending,
isOptimistic,
isError,
error,
isSuccess,
status, // "idle" | "pending" | "success" | "error"
execute,
executeFormAction,
setOptimistic,
reset,
} = useServerAction(myAction, {
// Callbacks
onStart: () => console.log("Started"),
onSuccess: ({ data }) => toast.success("Success!"),
onError: ({ err }) => toast.error(err.message),
onFinish: ([data, err]) => console.log("Finished"),
// Initial data
initialData: { count: 0 },
// Retry configuration
retry: {
maxAttempts: 3,
delay: 1000, // or (attempt, err) => attempt * 1000
},
// Persist states while pending
persistErrorWhilePending: false,
persistDataWhilePending: false,
});
Optimistic Updates
"use client";
import { useServerAction } from "zsa-react";
import { toggleLikeAction } from "./actions";
export function LikeButton({ postId, initialLikes }: Props) {
const { execute, data, isOptimistic, setOptimistic } = useServerAction(
toggleLikeAction,
{ initialData: { liked: false, count: initialLikes } }
);
const handleClick = async () => {
// Optimistically update UI
setOptimistic((current) => ({
liked: !current.liked,
count: current.liked ? current.count - 1 : current.count + 1,
}));
// Execute actual action (will rollback on error)
await execute({ postId });
};
return (
<button onClick={handleClick} className={isOptimistic ? "opacity-50" : ""}>
{data.liked ? "❤️" : "🤍"} {data.count}
</button>
);
}
Timeouts & Retries
"use server";
import { createServerAction } from "zsa";
import z from "zod";
export const slowAction = createServerAction()
.input(z.object({ data: z.string() }))
.timeout(5000) // 5 second timeout
.retry({
maxAttempts: 3,
delay: (attempt) => attempt * 1000, // Exponential backoff
})
.handler(async ({ input }) => {
// Long-running operation
return processData(input.data);
});
Best Practices
- Create procedures first, reuse across actions - don't create new procedures per action
- Throw descriptive errors -
throw new Error("Email already exists")for client display - Name destructured results -
const [categories, err] = await getCategoriesAction() - Call Service layer, NOT DAL directly - keep actions thin
- Always validate input with Zod schemas
- Use
revalidatePath/revalidateTagafter mutations - Keep actions thin - business logic belongs in services
File Structure
features/<feature>/usecases/
├── create/actions/create-<entity>-action.ts
├── update/actions/update-<entity>-action.ts
├── delete/actions/delete-<entity>-action.ts
└── list/actions/list-<entity>-action.ts
Action Pattern
// create-account-action.ts
'use server'
import 'server-only'
import { revalidatePath } from 'next/cache'
import { authedProcedure } from '@saas4dev/auth'
import { CreateAccountSchema, AccountSchema } from '@/features/accounts/model/account-schemas'
import { AccountService } from '@/features/accounts/account-service'
export const createAccountAction = authedProcedure
.createServerAction()
.input(CreateAccountSchema, { type: 'formData' })
.output(AccountSchema)
.onComplete(async () => {
revalidatePath('/accounts')
})
.handler(async ({ input, ctx }) => {
const result = await AccountService.create(ctx.userId, input)
if (!result.success) {
throw new Error(result.error)
}
return result.data
})
Required Directives
Every action file MUST include:
'use server' // First line - marks as server action
import 'server-only' // Prevents client import
React Query Integration
'use client'
import { useServerActionMutation, useServerActionQuery } from '@saas4dev/core'
// Mutations (create, update, delete)
const mutation = useServerActionMutation(createAction, {
onSuccess: () => toast.success('Created'),
onError: (error) => toast.error(error.message),
})
// Usage in forms
const form = useForm<Input>({ resolver: zodResolver(Schema) })
const onSubmit = (data: Input) => mutation.mutate(data)
// Queries (read, list)
const { data, isLoading } = useServerActionQuery(listAction, { input: { userId } })
Reference Files
references/procedures.md: Advanced procedure patterns, chaining, contextreferences/react-query.md: TanStack Query integration with ZSAreferences/forms.md: Form handling, validation, file uploads
More from gilbertopsantosjr/fullstacknextjs
gs-tanstack-react-query
TanStack React Query for data fetching with Clean Architecture. Queries return DTOs, mutations call server actions. Use when working with useQuery, useMutation, cache invalidation, or integrating ZSA server actions.
9tanstack-react-query
TanStack React Query expert for data fetching and mutations in React applications. Use when working with useQuery, useMutation, cache invalidation, optimistic updates, query keys, or integrating server actions with React Query via @saas4dev/core hooks (useServerActionQuery, useServerActionMutation, useServerActionInfiniteQuery). Triggers on requests involving API data fetching, server state management, cache strategies, or converting fetch/useEffect patterns to React Query.
4gs-feature-architecture
Guide for implementing features in Clean Architecture OOP with Next.js. Use when planning new features, understanding the 4-layer structure (Domain, Application, Infrastructure, Presentation), or deciding where code should live.
3sst-infra
Guide for AWS serverless infrastructure using SST v3 (Serverless Stack). Use when configuring deployment, creating stacks, managing secrets, setting up CI/CD, or deploying Next.js applications to AWS Lambda with DynamoDB.
2zod-validation
Guide for Zod schema validation patterns in TypeScript. Use when creating validation schemas, defining types, validating forms, API inputs, or handling validation errors.
2gs-sst-infra
Guide for AWS serverless infrastructure using SST v3. Covers DynamoDB, Next.js deployment, Lambda handlers with Clean Architecture adapter pattern, and CI/CD configuration.
2