Server Actions and Mutations in Next.js 15


Server Actions, as special asynchronous functions, are a powerful tool that always run on the **server**. They streamline tasks like form submissions, database updates, or API calls, eliminating the need for a separate API route. The significant advantage is the direct integration with React components—whether they’re **Server Components** or **Client Components-empowering you to handle complex tasks with ease**.
How to Define a Server Action
To mark a function as a Server Action, you use the React directive "use server". There are two common ways to do this:
- Place `"use server"` at the very top of an **async function** to make just that function a Server Action.
- Place `"use server"` at the top of a file to make **all exports in that file** Server Actions.
A Server Action Is a Public Endpoint
Even though a Server Action looks like a regular function in your code, it's still exposed as **publicly callable endpoint**. That means anyone could technically call it if they know the route.
We should never trust raw input. Always validate and sanitize data before it.
1"use server";23import { z } from "zod";45const formSchema = z.object({6 name: z.string().min(2),7 email: z.string().email(),8});910export async function submitForm(formData: FormData) {11 const parsed = formSchema.safeParse({12 name: formData.get("name"),13 email: formData.get("email"),14 });1516 if (!parsed.success) {17 throw new Error("Invalid data");18 }1920 // Safe to use parsed.data here21 // Example: save to DB22}
✅ Takeaway: Treat your Server Action like any API route. Validate input before using it, so you don’t end up with injection attacks or broken database entries.
Handling Pending States with useTransition
When you call a Server Action from a form, the request is asynchronous — the UI needs to show some kind of loading or pending state while waiting for the server to finish.
You could use useState to manage a loading flag, but that’s not the recommended approach in Next.js. Instead, use the useTransition hook.
Why?
- useTransition is specifically designed for handling async UI updates.
- It integrates with React’s concurrent rendering model, keeping the UI responsive.
- It plays nicely with revalidatePath() (or revalidateTag()), so when your Server Action triggers a revalidation, the UI updates correctly.
Here’s a simple example:
1"use client";23import { useTransition } from "react";4import { submitForm } from "./actions";56export default function ContactForm() {7 const [isPending, startTransition] = useTransition();89 async function handleSubmit(formData: FormData) {10 startTransition(async () => {11 await submitForm(formData);12 });13 }1415 return (16 <form action={handleSubmit}>17 <input name="name" placeholder="Your name" />18 <input name="email" type="email" placeholder="Your email" />19 <button type="submit" disabled={isPending}>20 {isPending ? "Sending..." : "Send"}21 </button>22 </form>23 );24}
✅ Takeaway: Use useTransition for form submissions with Server Actions. It ensures smooth loading states and proper revalidation handling — something useState alone can’t do.
Data Access Layer (DAL): keep mutations isolated and testable
Server Actions should orchestrate the request -> validation -> mutation -> revalidation flow, not contain business logic or raw SQL. By pushing data work into a Data Access Layer (DAL), we get clearer code, easier testing, safer transactions, and a single place to enforce rules (uniqueness, idempotency, ownership checks).
Core principles
- Single responsibility: actions call services; services call repositories; repositories talk to the DB.
- Validation at the edge, invariants in the core: use Zod in the action to validate input; enforce business rules in services.
- Transactions for multi-step changes: keep related writes atomic.
- Idempotency for form resubmits: protect against double-clicks / retries.
- Return typed results, not raw DB rows: map DB to domain types.
- No client DB access: all DB calls stay on the server (repositories/services only).
1// app/actions/orders.ts23"use server";45import { z } from "zod";6import { createOrder } from "@/lib/services/order.service";7import { revalidatePath } from "next/cache";89const orderSchema = z.object({10 userId: z.string().uuid(),11 productId: z.string().uuid(),12 quantity: z.number().int().positive(),13 // Optional idempotency key from the client (hidden input or header)14 idemKey: z.string().min(10).optional(),15});1617export async function createOrderAction(formData: FormData) {18 const parsed = orderSchema.safeParse({19 userId: formData.get("userId"),20 productId: formData.get("productId"),21 quantity: Number(formData.get("quantity")),22 idemKey: formData.get("idemKey") ?? undefined,23 });2425 if (!parsed.success) {26 return { ok: false, error: "Invalid data." };27 }2829 const res = await createOrder(parsed.data);3031 if (!res.ok) return { ok: false, error: res.error };3233 // Revalidate any pages or layouts that show orders34 revalidatePath("/orders");35 return { ok: true, data: res.data };36}
1// lib/services/order.service.ts23import { prisma } from "@/lib/db/client";4import { ensureStock } from "./stock.rules";5import { findById, create as repoCreate, findByIdemKey } from "@/lib/repositories/order.repo";67type CreateOrderInput = {8 userId: string;9 productId: string;10 quantity: number;11 idemKey?: string;12};1314export async function createOrder(input: CreateOrderInput) {15 // Idempotency: if a key exists and we’ve processed it already, return the same result.16 if (input.idemKey) {17 const existing = await findByIdemKey(input.idemKey);18 if (existing) return { ok: true as const, data: existing };19 }2021 try {22 const result = await prisma.$transaction(async (tx) => {23 // Business rule example: ensure stock is available24 await ensureStock(tx, input.productId, input.quantity);2526 const order = await repoCreate(tx, {27 userId: input.userId,28 productId: input.productId,29 quantity: input.quantity,30 idemKey: input.idemKey ?? null,31 status: "PLACED",32 });3334 // (Optional) log event, decrement stock, enqueue email, etc.35 return order;36 });3738 return { ok: true as const, data: result };39 } catch (err: any) {40 // Map low-level DB errors to user-safe messages41 if (err.code === "P2002") return { ok: false as const, error: "Duplicate order." };42 if (err.name === "OutOfStockError") return { ok: false as const, error: "Not enough stock." };43 return { ok: false as const, error: "Could not create order." };44 }45}
1// lib/repositories/order.repo.ts23import { Prisma, PrismaClient } from "@prisma/client";45type Tx = PrismaClient | Prisma.TransactionClient;67export function findByIdemKey(idemKey: string, tx?: Tx) {8 const db = tx as Tx;9 return db.order.findFirst({ where: { idemKey } });10}1112export function findById(id: string, tx?: Tx) {13 const db = tx as Tx;14 return db.order.findUnique({ where: { id } });15}1617export function create(18 tx: Tx,19 data: { userId: string; productId: string; quantity: number; status: string; idemKey: string | null }20) {21 return tx.order.create({ data });22}
1// lib/services/stock.rules.ts234import { Prisma, PrismaClient } from "@prisma/client";5type Tx = PrismaClient | Prisma.TransactionClient;67export async function ensureStock(tx: Tx, productId: string, qty: number) {8 const product = await tx.product.findUnique({ where: { id: productId }, select: { stock: true } });9 if (!product || product.stock < qty) {10 const e = new Error("OutOfStockError");11 e.name = "OutOfStockError";12 throw e;13 }14}

