tRPC gives you type-safe API calls without REST endpoints or code generation. Your frontend knows exactly what your backend returns.
Step 1: Install Dependencies
pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query superjson zod
Step 2: Initialize tRPC Server
// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/lib/auth";
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
session,
...opts,
};
};
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const createTRPCRouter = t.router;
// Middleware
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: { session: { ...ctx.session, user: ctx.session.user } },
});
});
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
Step 3: Define Routers
// server/routers/posts.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
export const postsRouter = createTRPCRouter({
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ input }) => {
const items = await db
.select()
.from(posts)
.orderBy(desc(posts.createdAt))
.limit(input.limit + 1);
let nextCursor: string | undefined;
if (items.length > input.limit) {
const nextItem = items.pop();
nextCursor = nextItem?.id;
}
return { items, nextCursor };
}),
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.id, input.id));
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const [post] = await db
.insert(posts)
.values({
...input,
authorId: ctx.session.user.id,
})
.returning();
return post;
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).optional(),
published: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const [post] = await db
.update(posts)
.set(data)
.where(eq(posts.id, id))
.returning();
return post;
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
await db.delete(posts).where(eq(posts.id, input.id));
return { success: true };
}),
});
Step 4: Root Router
// server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { postsRouter } from "./posts";
export const appRouter = createTRPCRouter({
posts: postsRouter,
});
export type AppRouter = typeof appRouter;
Step 5: tRPC API Route Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
Step 6: Client Setup
// lib/trpc.ts
"use client";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
// components/providers/TRPCProvider.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
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",
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
Step 7: Use in Components
"use client";
import { trpc } from "@/lib/trpc";
export function PostList() {
// Fully type-safe query β IDE autocomplete works
const { data, isLoading } = trpc.posts.list.useQuery({ limit: 10 });
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate the list query to refetch
utils.posts.list.invalidate();
},
});
const utils = trpc.useUtils();
if (isLoading) return <p>Loading...</p>;
return (
<div>
<button
onClick={() =>
createPost.mutate({
title: "New Post",
content: "Hello world",
})
}
>
Create Post
</button>
{data?.items.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
{/* post.title is typed as string */}
{/* post.nonexistent would show a TypeScript error */}
</div>
))}
</div>
);
}
Need a Type-Safe Web Application?
We build robust web applications with full type safety, modern tooling, and clean architecture. Contact us to discuss your project.