In the previous tutorial, we learned about Zod for runtime validation. Now let’s learn about tRPC — a library that gives you end-to-end type safety from server to client without writing any API schema.
By the end of this tutorial, you will know how to set up tRPC, create procedures, validate input with Zod, use it with Next.js, and understand when tRPC is the right choice.
What is tRPC?
tRPC lets you call server functions directly from the client with full type safety. No REST endpoints. No GraphQL schemas. No code generation.
When you change a function on the server, the client gets updated types instantly. If you break the contract, TypeScript shows an error at compile time.
Server: Define a function → Client: Call it with types → No API layer to maintain
How tRPC Works
- You define procedures (functions) on the server
- You export the router type (just the type, not the actual code)
- The client imports the type and calls procedures like regular functions
- TypeScript ensures the input and output types match
No manually written REST endpoints, no shared schemas to maintain, no JSON parsing in your client code. tRPC manages the HTTP transport layer automatically.
Setting Up tRPC with Next.js
Install Dependencies
npm install @trpc/server @trpc/client @trpc/next @trpc/react-query @tanstack/react-query zod
Create the tRPC Server
// src/server/trpc.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
Define Your Router
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
export const userRouter = router({
// Query — read data
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// input is typed: { id: string }
const user = await findUser(input.id);
return user; // Return type is inferred
}),
// Query — list data
list: publicProcedure
.query(async () => {
return await getAllUsers();
}),
// Mutation — write data
create: publicProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
// input is typed: { name: string; email: string }
return await createUser(input);
}),
// Mutation — delete data
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
await deleteUser(input.id);
return { success: true };
}),
});
Create the App Router
// src/server/routers/index.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { noteRouter } from "./note";
export const appRouter = router({
user: userRouter,
note: noteRouter,
});
// Export the type — this is what the client imports
export type AppRouter = typeof appRouter;
Set Up the Next.js API Handler
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
});
export { handler as GET, handler as POST };
Client Setup with React Query
Create the tRPC Client
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers";
export const trpc = createTRPCReact<AppRouter>();
Provider Setup
"use client";
// src/app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import { trpc } from "@/lib/trpc";
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Using tRPC in Components
Queries — Reading Data
"use client";
import { trpc } from "@/lib/trpc";
export function UserList() {
const { data: users, isLoading, error } = trpc.user.list.useQuery();
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
);
}
TypeScript knows the type of users based on what the server returns. You get full autocomplete.
Queries with Input
export function UserProfile({ userId }: { userId: string }) {
const { data: user } = trpc.user.getById.useQuery({ id: userId });
// TypeScript knows: id must be a string
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Mutations — Writing Data
"use client";
import { useState } from "react";
import { trpc } from "@/lib/trpc";
export function CreateUserForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Refresh the user list after creating
utils.user.list.invalidate();
setName("");
setEmail("");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createUser.mutate({ name, email });
// TypeScript enforces: name must be string, email must be string
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Name" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? "Creating..." : "Create User"}
</button>
{createUser.error && <p>Error: {createUser.error.message}</p>}
</form>
);
}
Input Validation with Zod
tRPC uses Zod schemas for input validation. Invalid input is rejected before your function runs:
const noteRouter = router({
create: publicProcedure
.input(z.object({
title: z.string().min(1, "Title is required").max(200),
content: z.string().min(1, "Content is required"),
tags: z.array(z.string()).max(5).optional(),
}))
.mutation(async ({ input }) => {
// input is guaranteed to be valid here
return await createNote(input);
}),
});
If the client sends invalid data, tRPC returns a 400 error with Zod’s error messages. The server function never runs.
Middleware
Middleware runs before your procedures. Use it for authentication, logging, or rate limiting:
import { TRPCError } from "@trpc/server";
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
user: ctx.session.user, // user is now available in procedures
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
Use protectedProcedure instead of publicProcedure for authenticated routes:
const noteRouter = router({
myNotes: protectedProcedure
.query(async ({ ctx }) => {
// ctx.user is typed and guaranteed to exist
return await getNotesByUser(ctx.user.id);
}),
});
Error Handling
tRPC uses typed errors:
import { TRPCError } from "@trpc/server";
const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await findUser(input.id);
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: `User ${input.id} not found`,
});
}
return user;
}),
});
Available error codes: UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, INTERNAL_SERVER_ERROR, TIMEOUT, CONFLICT, and more.
On the client, errors are typed:
const { error } = trpc.user.getById.useQuery({ id: "123" });
if (error) {
console.log(error.message); // "User 123 not found"
console.log(error.data?.code); // "NOT_FOUND"
}
tRPC vs REST vs GraphQL
| Feature | tRPC | REST | GraphQL |
|---|---|---|---|
| Type safety | End-to-end | Manual (OpenAPI) | With codegen |
| Schema/contract | None needed | OpenAPI spec | SDL |
| Code generation | None | Optional | Required |
| Learning curve | Low | Low | Medium |
| Client library | Required | Any HTTP client | Apollo, urql |
| Non-TS clients | No | Yes | Yes |
| Public APIs | No | Yes | Yes |
When to Use tRPC
- Full-stack TypeScript projects (both server and client)
- Internal APIs that only your frontend consumes
- Projects where you want maximum developer experience
- Next.js, Remix, or similar full-stack frameworks
When NOT to Use tRPC
- Public APIs consumed by non-TypeScript clients
- Mobile apps with native code (Swift, Kotlin)
- Microservices with different languages
- When you need a formal API specification
What’s Next?
In the next tutorial, we will learn about advanced TypeScript patterns — discriminated unions, branded types, builder pattern, and type-safe event systems.