The Next.js 16 mutation pattern is the same five steps, every time:
1// app/courses/actions.ts
2"use server";
3import { z } from "zod";
4import { redirect } from "next/navigation";
5import { revalidateTag, revalidatePath } from "next/cache";
6import { db } from "@/lib/db";
7import { requireUser } from "@/lib/auth-guard";
8
9const schema = z.object({
10 title: z.string().min(3).max(200),
11 description: z.string().min(20).max(5000),
12});
13
14type Result =
15 | { ok: true; slug: string }
16 | { ok: false; errors: Record<string, string> };
17
18export async function createCourse(prev: unknown, formData: FormData): Promise<Result> {
19 // 1. validate
20 const parsed = schema.safeParse(Object.fromEntries(formData));
21 if (!parsed.success) {
22 return {
23 ok: false,
24 errors: Object.fromEntries(parsed.error.issues.map((i) => [i.path[0] as string, i.message])),
25 };
26 }
27
28 // 2. auth
29 const user = await requireUser();
30
31 // 3. mutate
32 const slug = slugify(parsed.data.title);
33 const course = await db.course.create({
34 data: {
35 ...parsed.data,
36 slug,
37 createdById: user.id,
38 },
39 });
40
41 // 4. invalidate
42 revalidateTag("course");
43 revalidatePath("/dashboard");
44
45 // 5. return / redirect
46 redirect(`/courses/${course.slug}/edit`);
47}When two writes must succeed together:
1await db.$transaction(async (tx) => {
2 const order = await tx.order.create({ data: { userId, total } });
3 await tx.orderItem.createMany({ data: items.map((i) => ({ orderId: order.id, ...i })) });
4 await tx.user.update({ where: { id: userId }, data: { spent: { increment: total } } });
5});If anything throws, the entire transaction rolls back.
For mutations that should not double-charge or double-book:
1export async function purchase(courseId: string, idempotencyKey: string) {
2 const existing = await db.payment.findUnique({ where: { idempotencyKey } });
3 if (existing) return existing;
4 // … create payment with this key
5}Pass a UUID from the client and store it as a unique column.
If a row may be needed for audit / restoration, prefer:
1model Course {
2 deletedAt DateTime?
3 // …
4}1await db.course.update({ where: { id }, data: { deletedAt: new Date() } });Wrap reads in a helper that filters deletedAt: null by default.
1await db.enrollment.createMany({
2 data: students.map((s) => ({ userId: s.id, courseId })),
3 skipDuplicates: true,
4});Far faster than N round trips. Skip duplicates avoids errors on @@unique constraints.
Cursor-based — scales to any size:
1const PAGE = 20;
2const courses = await db.course.findMany({
3 take: PAGE + 1, // fetch one extra to know if there's another page
4 cursor: cursor ? { id: cursor } : undefined,
5 orderBy: { createdAt: "desc" },
6});
7const hasMore = courses.length > PAGE;
8const items = hasMore ? courses.slice(0, PAGE) : courses;
9const nextCursor = hasMore ? items[items.length - 1].id : null;