Back to blog
web development

Server Actions and Mutations in Next.js 15

·5 min read
Piotr
Piotr
Founder
main-server-action-article

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.

typescript
1"use server";
2
3import { z } from "zod";
4
5const formSchema = z.object({
6 name: z.string().min(2),
7 email: z.string().email(),
8});
9
10export async function submitForm(formData: FormData) {
11 const parsed = formSchema.safeParse({
12 name: formData.get("name"),
13 email: formData.get("email"),
14 });
15
16 if (!parsed.success) {
17 throw new Error("Invalid data");
18 }
19
20 // Safe to use parsed.data here
21 // Example: save to DB
22}

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:

typescript
1"use client";
2
3import { useTransition } from "react";
4import { submitForm } from "./actions";
5
6export default function ContactForm() {
7 const [isPending, startTransition] = useTransition();
8
9 async function handleSubmit(formData: FormData) {
10 startTransition(async () => {
11 await submitForm(formData);
12 });
13 }
14
15 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).

typescript
1// app/actions/orders.ts
2
3"use server";
4
5import { z } from "zod";
6import { createOrder } from "@/lib/services/order.service";
7import { revalidatePath } from "next/cache";
8
9const 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});
16
17export 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 });
24
25 if (!parsed.success) {
26 return { ok: false, error: "Invalid data." };
27 }
28
29 const res = await createOrder(parsed.data);
30
31 if (!res.ok) return { ok: false, error: res.error };
32
33 // Revalidate any pages or layouts that show orders
34 revalidatePath("/orders");
35 return { ok: true, data: res.data };
36}

typescript
1// lib/services/order.service.ts
2
3import { prisma } from "@/lib/db/client";
4import { ensureStock } from "./stock.rules";
5import { findById, create as repoCreate, findByIdemKey } from "@/lib/repositories/order.repo";
6
7type CreateOrderInput = {
8 userId: string;
9 productId: string;
10 quantity: number;
11 idemKey?: string;
12};
13
14export 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 }
20
21 try {
22 const result = await prisma.$transaction(async (tx) => {
23 // Business rule example: ensure stock is available
24 await ensureStock(tx, input.productId, input.quantity);
25
26 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 });
33
34 // (Optional) log event, decrement stock, enqueue email, etc.
35 return order;
36 });
37
38 return { ok: true as const, data: result };
39 } catch (err: any) {
40 // Map low-level DB errors to user-safe messages
41 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}

typescript
1// lib/repositories/order.repo.ts
2
3import { Prisma, PrismaClient } from "@prisma/client";
4
5type Tx = PrismaClient | Prisma.TransactionClient;
6
7export function findByIdemKey(idemKey: string, tx?: Tx) {
8 const db = tx as Tx;
9 return db.order.findFirst({ where: { idemKey } });
10}
11
12export function findById(id: string, tx?: Tx) {
13 const db = tx as Tx;
14 return db.order.findUnique({ where: { id } });
15}
16
17export 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}

typescript
1// lib/services/stock.rules.ts
2
3
4import { Prisma, PrismaClient } from "@prisma/client";
5type Tx = PrismaClient | Prisma.TransactionClient;
6
7export 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}

About the author

Piotr
Piotr
Founder

Results-driven and forward-thinking Senior Leader in Payments, Banking & Finance with expertise in AI, Full Stack Development, and Python programming.