Developing tRPC API for Web Application
tRPC (TypeScript RPC)—a library for building end-to-end typed APIs without schemas or code generation. TypeScript types automatically flow from server procedures to client calls. Works only in TypeScript ecosystem and is most convenient in monorepos or fullstack frameworks (Next.js, Remix, SvelteKit).
Key Idea
// Server defines procedure
const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.user.findUnique({ where: { id: input.id } });
}),
});
// Client calls with full typing—no code generation
const user = await trpc.getUser.query({ id: 'user_123' });
// TypeScript knows type of user: { id: string; name: string; ... } | null
No REST endpoints, no OpenAPI schemas, no GraphQL—just functions with types.
Setup (Next.js + tRPC v11)
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
Router and Procedures
// server/routers/articles.ts
export const articlesRouter = router({
list: publicProcedure
.input(z.object({ page: z.number().default(1), limit: z.number().max(100).default(20) }))
.query(async ({ input, ctx }) => {
const [items, total] = await ctx.db.$transaction([
ctx.db.article.findMany({ skip: (input.page - 1) * input.limit, take: input.limit }),
ctx.db.article.count(),
]);
return { items, total, pages: Math.ceil(total / input.limit) };
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(200), body: z.string().min(10) }))
.mutation(async ({ input, ctx }) =>
ctx.db.article.create({ data: { ...input, authorId: ctx.user.id } })
),
delete: protectedProcedure
.input(z.string())
.mutation(async ({ input: id, ctx }) => {
const article = await ctx.db.article.findUnique({ where: { id } });
if (!article) throw new TRPCError({ code: 'NOT_FOUND' });
if (article.authorId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
return ctx.db.article.delete({ where: { id } });
}),
});
Client with React Query
// Auto-typing, caching via React Query
function ArticleList() {
const { data, isLoading } = trpc.articles.list.useQuery({ page: 1 });
const createMutation = trpc.articles.create.useMutation({
onSuccess: () => utils.articles.list.invalidate(),
});
if (isLoading) return <Spinner />;
return (
<div>
{data?.items.map(article => <ArticleCard key={article.id} {...article} />)}
<button onClick={() => createMutation.mutate({ title: '...', body: '...' })}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</button>
</div>
);
}
When to Use tRPC
Ideal for: fullstack TypeScript monorepos, minimal API overhead, internal microservice APIs, rapid iteration with shared types.
Not suitable for: public APIs with non-TS clients, need for strict REST/GraphQL contracts, teams without TypeScript expertise.
Timelines
Basic tRPC setup (router, procedures, client hooks): 1–2 days. With authentication, database integration, complex input validation: 3–5 days.







