tRPC lets you build fully type-safe APIs without schemas, code generation, or REST conventions. Define a function on the server — call it from the client with full autocompletion.
Why tRPC?
- Zero code generation — types flow automatically from server to client
- Full autocompletion — IntelliSense for every API call
- No REST — no routes, no HTTP methods, no serialization headaches
- Works with — Next.js, Nuxt, SvelteKit, Express, Fastify
Quick Start
npm install @trpc/server @trpc/client @trpc/react-query
Server
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { user: ctx.user } });
});
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
export const router = t.router;
// server/routers/users.ts
export const userRouter = router({
list: publicProcedure.query(async () => {
return await db.select().from(users);
}),
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return await db.select().from(users).where(eq(users.id, input.id));
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.insert(users).values(input).returning();
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input: { id, ...data } }) => {
return await db.update(users).set(data).where(eq(users.id, id)).returning();
}),
});
Client (React Query)
import { trpc } from '../utils/trpc';
function UserList() {
const { data: users, isLoading } = trpc.user.list.useQuery();
const createUser = trpc.user.create.useMutation({
onSuccess: () => utils.user.list.invalidate(),
});
if (isLoading) return <p>Loading...</p>;
return (
<div>
<ul>
{users?.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
<button onClick={() => createUser.mutate({ name: 'Alice', email: 'a@b.com' })}>
Add User
</button>
</div>
);
}
Subscriptions (Real-Time)
// Server
onNewMessage: publicProcedure.subscription(() => {
return observable<Message>((emit) => {
const onMessage = (msg: Message) => emit.next(msg);
ee.on('message', onMessage);
return () => ee.off('message', onMessage);
});
}),
// Client
trpc.onNewMessage.useSubscription(undefined, {
onData: (message) => {
setMessages((prev) => [...prev, message]);
},
});
Next.js App Router
// 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,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Error Handling
import { TRPCError } from '@trpc/server';
deleteUser: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const user = await db.select().from(users).where(eq(users.id, input.id));
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
if (user.id !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
await db.delete(users).where(eq(users.id, input.id));
}),
Building type-safe data APIs? Check out my Apify actors for structured web data, or email spinov001@gmail.com for custom tRPC solutions.
tRPC, REST, or GraphQL — which API style do you prefer? Comment below!
Top comments (0)