API
Learn how to create end-to-end type-safe APIs with tRPC.
tRPC (TypeScript Remote Procedure Call) creates end-to-end type-safe APIs. Combined with Tanstack Query, tRPC provides a seamless developer experience with autocompletion and automatic type inference. This also means better AI-assisted development.
Why tRPC?
- Automatic Type Safety — Types are written once on the server and automatically inferred on the client.
- Autocompletion — Full IntelliSense for available routes, arguments, and responses.
- Isolated Server Logic — Server logic is in a dedicated directory, keeping it separate from your frontend code.
- Framework Agnostic — If you move your backend to another framework, your API routes come with you.
- Scalable Structure — Clean three-layer architecture promotes code reusability and separation of concerns.
Vocabulary
| Term | Description |
|---|---|
| Procedure | API endpoint — can be a query or mutation. |
| Query | Procedure that reads data. |
| Mutation | Procedure that creates, updates, or deletes data. |
| Router | Collection of procedures under a shared namespace. |
| Context | Data every procedure can access (session, database client). |
| Middleware | Function that run code before and after a procedure. Can modify context. |
| Validation | "Does this input data contain the right stuff?" |
Architecture
The API follows a three-layer pattern: Router → Service → Repository.
- Router: Thin controller layer that maps procedures to service methods.
- Service: Business logic, feature flag checks, error handling.
- Repository: Pure Prisma queries with explicit field selection.
- Input: Zod schemas for procedure input validation.
Protecting API Routes
Three procedure types are pre-configured:
/**
* Public procedure
* No authentication is required.
*/
export const publicProcedure = t.procedure;
/**
* Protected procedure
* Requires an authenticated session.
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
const user = ctx.session?.user;
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
session: {
...ctx.session,
user,
},
},
});
});
/**
* Admin procedure
* Requires an authenticated session with admin role.
*/
export const adminProcedure = t.procedure.use(async ({ ctx, next }) => {
const user = ctx.session?.user;
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (user.role !== Role.ADMIN) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// Confirm the user has the admin role in the database to prevent role hijacking
const isAdmin = await userService.isAdmin(user.id);
if (!isAdmin) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next({
ctx: {
session: {
...ctx.session,
user,
},
},
});
});publicProcedure: Truly public data (health checks, public listings).protectedProcedure: Any authenticated user action (default).adminProcedure: Admin-only operations.
Creating API Routes
Create the repository
Create packages/api/src/routers/comment/comment.repository.ts:
import { db } from "@package/db";
import type { CreateCommentInput } from "./comment.input";
export const commentRepository = {
getAll() {
return db.comment.findMany({
select: { id: true, text: true, createdAt: true },
});
},
async create(data: CreateCommentInput) {
return db.comment.create({
data: { text: data.text },
select: { id: true },
});
},
};Create the input schema
Create packages/api/src/routers/comment/comment.input.ts:
import { z } from "zod";
export const createCommentInput = z.object({
text: z.string().min(1).max(500),
});
export type CreateCommentInput = z.infer<typeof createCommentInput>;Create the service
Create packages/api/src/routers/comment/comment.service.ts:
import { commentRepository } from "./comment.repository";
import type { CreateCommentInput } from "./comment.input";
export const commentService = {
getAll() {
return commentRepository.getAll();
},
async create(input: CreateCommentInput) {
return commentRepository.create(input);
},
};Create the router
Create packages/api/src/routers/comment/comment.router.ts:
import { protectedProcedure, publicProcedure, router } from "../../trpc";
import { createCommentInput } from "./comment.input";
import { commentService } from "./comment.service";
export const commentRouter = router({
getAll: publicProcedure.query(() => {
return commentService.getAll();
}),
create: protectedProcedure.input(createCommentInput).mutation(({ input }) => {
return commentService.create(input);
}),
});Register the router
Add it to packages/api/src/root.ts:
import { commentRouter } from "./routers/comment/comment.router";
export const appRouter = router({
comment: commentRouter,
// ...other routers
});Calling API Routes
Server Components
import { api } from "@/lib/trpc/server";
export default async function CommentsPage() {
const comments = await api.comment.getAll();
return (
<section>
{comments.map((comment) => (
<p key={comment.id}>{comment.text}</p>
))}
</section>
);
}Client Components
"use client";
import { api } from "@/lib/trpc/react";
export function Comments() {
const { data, isLoading } = api.comment.getAll.useQuery();
if (isLoading) {
return <Spinner />;
}
return (
<section>
{data?.map((comment) => (
<p key={comment.id}>{comment.text}</p>
))}
</section>
);
}Mutations
"use client";
import { api } from "@/lib/trpc/react";
export function AddComment() {
const utils = api.useUtils();
const mutation = api.comment.create.useMutation({
onSuccess: async () => {
await utils.comment.getAll.invalidate();
},
});
return (
<button onClick={() => mutation.mutate({ text: "Hello!" })} disabled={mutation.isPending}>
Add Comment
</button>
);
}Always invalidate related queries after mutations to refresh the data. Use utils.{router}.{procedure}.invalidate().