TL;DR: tRPC lets you call backend functions from your frontend like they're local functions — with full TypeScript autocomplete and type checking. No REST endpoints to define, no API schemas to write, no code generation step. You write a function on the server, and TypeScript instantly knows what it accepts and returns on the client. The catch: both your frontend and backend must be TypeScript. For full-stack Next.js apps, it's the fastest way to build an API. For public APIs that other people consume, stick with REST.
Why AI Coders Need This
When you ask AI to build a full-stack Next.js app, it has a choice: create REST API routes with fetch calls, set up GraphQL with schemas and resolvers, or use tRPC. Increasingly, AI chooses tRPC — and there's a practical reason why.
With REST, AI has to generate code in two places that must stay perfectly in sync: the API endpoint that returns data, and the frontend code that calls it. If the endpoint returns { userName: string } but the frontend expects { username: string } (note the casing), things break silently at runtime. You won't see the bug until a user reports a blank screen.
tRPC eliminates this entire category of bugs. The TypeScript compiler catches mismatches before the code ever runs. AI generates less code, the code has fewer bugs, and you get autocomplete on every API call. That's why you're seeing it in AI-generated projects.
As of 2026, tRPC has over 36,000 GitHub stars and is used in production by Cal.com, Ping.gg, and thousands of Next.js applications. It was created by Alex "KATT" Johansson and has become the default API layer for the T3 Stack (Next.js + tRPC + Prisma + Tailwind).
Real Scenario
You're building a task management app. Users need to create tasks, list their tasks, and mark them complete. Instead of wiring up three separate REST endpoints, you want a single typed API that your frontend can call with autocomplete.
Prompt You'd Type
Build a task management API using tRPC and Next.js 15:
- createTask: takes title (string) and description (optional string)
- listTasks: returns all tasks for the current user
- toggleComplete: takes taskId, flips the completed status
- Use Zod for input validation
- Set up the tRPC client so I can call these from React components
- Show me how to call each procedure from the frontend
What AI Generated
Here's what AI produces — a complete tRPC setup with three procedures. Let's look at the server-side router first, then the client-side calls.
Server: The tRPC Router
// server/trpc.ts — Initialize tRPC
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
// Create the tRPC instance
const t = initTRPC.context<{ userId: string | null }>().create();
// Export reusable pieces
export const router = t.router;
export const publicProcedure = t.procedure;
// Middleware that requires authentication
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { userId: ctx.userId } });
});
export const protectedProcedure = t.procedure.use(isAuthenticated);
// server/routers/tasks.ts — The task router
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc';
import { db } from '../db'; // your database client
export const taskRouter = router({
// CREATE — takes validated input, returns the new task
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
}))
.mutation(async ({ input, ctx }) => {
const task = await db.task.create({
data: {
title: input.title,
description: input.description,
userId: ctx.userId,
completed: false,
},
});
return task;
}),
// LIST — no input needed, returns user's tasks
list: protectedProcedure
.query(async ({ ctx }) => {
return db.task.findMany({
where: { userId: ctx.userId },
orderBy: { createdAt: 'desc' },
});
}),
// TOGGLE — takes a task ID, flips completed status
toggleComplete: protectedProcedure
.input(z.object({ taskId: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const task = await db.task.findFirst({
where: { id: input.taskId, userId: ctx.userId },
});
if (!task) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return db.task.update({
where: { id: input.taskId },
data: { completed: !task.completed },
});
}),
});
// server/routers/_app.ts — Combine all routers
import { router } from '../trpc';
import { taskRouter } from './tasks';
export const appRouter = router({
tasks: taskRouter,
});
// THIS is the magic line — export the type
export type AppRouter = typeof appRouter;
Client: Calling the API from React
// utils/trpc.ts — Create the typed client
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
// This single line connects your frontend types
// to your backend types — no code generation needed
export const trpc = createTRPCReact<AppRouter>();
// components/TaskList.tsx — Using tRPC in a component
import { trpc } from '../utils/trpc';
export function TaskList() {
// TypeScript KNOWS this returns Task[] — full autocomplete
const { data: tasks, isLoading } = trpc.tasks.list.useQuery();
// TypeScript KNOWS this takes { taskId: string }
const toggleMutation = trpc.tasks.toggleComplete.useMutation({
onSuccess: () => {
// Refetch the list after toggling
utils.tasks.list.invalidate();
},
});
const utils = trpc.useUtils();
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>
<button onClick={() => toggleMutation.mutate({
taskId: task.id // autocomplete! TypeScript enforces UUID
})}>
{task.completed ? '✅' : '⬜'} {task.title}
</button>
</li>
))}
</ul>
);
}
Notice what's missing: no fetch('/api/tasks') calls. No response type casting. No hoping the API returns what you expect. You type trpc.tasks. and your editor shows create, list, and toggleComplete — with full argument types and return types. If you rename a field on the server, the client shows a red squiggly line immediately.
tRPC vs REST vs GraphQL
| Feature | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual (hope types match) | Schema + codegen | Automatic (TypeScript infers) |
| Boilerplate | Medium (endpoints + fetch) | High (schema + resolvers + codegen) | Low (just write functions) |
| Learning curve | Low | High | Medium |
| Language support | Any language | Any language | TypeScript only |
| Public API friendly | Yes — standard HTTP | Yes — standard spec | No — internal use only |
| Caching | Built-in HTTP caching | Requires client-side cache | React Query cache |
| Best for | Public APIs, simple CRUD | Complex data, multi-client | Full-stack TypeScript apps |
| AI generation speed | Fast | Slow (many files) | Fastest (least code) |
Think of it this way: REST is a public restaurant menu — anyone can order. GraphQL is a custom buffet — you pick exactly what you want. tRPC is cooking in your own kitchen — incredibly efficient, but you're not serving strangers.
Understanding the Code
Routers
A router in tRPC is a group of related API operations — like a controller in REST. The taskRouter above groups create, list, and toggleComplete together. You can nest routers: appRouter contains taskRouter, so on the client you call trpc.tasks.list. The dot notation matches the nesting.
Each router is just a plain object with named procedures. No decorators, no config files, no route registration — you define it and it exists.
Procedures (Queries and Mutations)
A procedure is a single API operation. tRPC has two main types:
- Query: reads data. Called with
useQuery(). Automatically refetched, cached, and deduplicated by React Query. Like a GET request. - Mutation: writes data. Called with
useMutation(). Runs once when triggered. Like POST, PUT, or DELETE.
The chain protectedProcedure.input(schema).query(handler) reads naturally: start with a protected procedure, validate the input against a Zod schema, then run the query handler. Each step adds type information that flows all the way to the client.
Context
Context is data available to every procedure — usually the current user's session, database connection, or authentication state. You create it once when the request arrives, and every procedure can access it via ctx.
// The context creator — runs for every request
export async function createContext(opts: { req: Request }) {
const session = await getSession(opts.req);
return {
userId: session?.userId ?? null,
db: prisma, // database client
};
}
Context is how tRPC knows who's making the request without you passing user IDs around manually. When AI generates middleware that checks authentication, it's reading from context.
Middleware
Middleware in tRPC runs before your procedure handler — like a bouncer checking IDs at the door. The isAuthenticated middleware above checks if ctx.userId exists. If not, it throws an error before the procedure ever runs. If yes, it passes the verified context forward.
You can chain middleware: authentication → rate limiting → logging. Each one transforms or validates the context before the next one runs.
What AI Gets Wrong About tRPC
1. Using tRPC for public APIs
AI sometimes sets up tRPC for an API that other services or third-party developers will consume. This is a mistake. tRPC's type safety works because both the client and server share the same TypeScript types. If someone is calling your API from Python, Go, or plain JavaScript, they get none of those benefits — they just get a weird RPC protocol that's harder to work with than REST.
The fix: If external consumers will call your API, use REST or GraphQL. If it's internal to your full-stack TypeScript app, tRPC is the right choice.
2. Over-nesting routers
AI loves organization, so it creates deeply nested routers like trpc.app.features.tasks.management.create. This makes client-side calls unnecessarily verbose and provides no real benefit.
The fix: Keep nesting to one or two levels. trpc.tasks.create is fine. trpc.admin.users.list is fine. Anything deeper is probably over-engineering.
3. Ignoring error handling
AI often generates the "happy path" — the code that works when everything goes right. But tRPC procedures can fail: invalid input, database errors, authorization failures. AI frequently forgets to handle these on the client side.
// ❌ What AI generates — no error handling
const { data } = trpc.tasks.create.useMutation();
// ✅ What you actually need
const createTask = trpc.tasks.create.useMutation({
onError: (error) => {
if (error.data?.code === 'UNAUTHORIZED') {
// redirect to login
} else if (error.data?.code === 'BAD_REQUEST') {
// show validation errors
console.error(error.message);
} else {
// generic error
toast.error('Something went wrong. Try again.');
}
},
onSuccess: (newTask) => {
toast.success(`Created: ${newTask.title}`);
utils.tasks.list.invalidate();
},
});
The tRPC Decision
tRPC is powerful — but only in its lane. If your entire stack is TypeScript and you control both the frontend and backend, tRPC saves real time and prevents real bugs. The moment an external consumer enters the picture, switch to REST or GraphQL. Know your audience before committing to tRPC.
When to Use tRPC vs. When Not To
Use tRPC When
- Full-stack TypeScript (Next.js, Remix, SvelteKit)
- Same team owns frontend and backend
- Internal APIs — not consumed externally
- You want maximum type safety with minimum code
- Rapid prototyping where speed matters
Don't Use tRPC When
- Public API consumed by third parties
- Clients are not TypeScript (mobile apps, Python, Go)
- You need REST's HTTP caching (CDN caching)
- Multiple teams own different parts of the stack
- You need a standard API spec (OpenAPI/Swagger)
What to Learn Next
What Is a REST API?
The API paradigm most projects use. Understand REST first — tRPC is an alternative to it.
What Is GraphQL?
The other API alternative. Know when GraphQL is the right call over tRPC.
What Is TypeScript?
tRPC only works because of TypeScript's type system. Understand the foundation.
What Is Next.js?
The framework tRPC pairs with most often. Learn the full-stack foundation.
Next Step
Ask your AI to scaffold a Next.js app with tRPC: "Create a Next.js 15 app with tRPC, a simple notes router with create/list/delete procedures, and a React component that calls all three." Then open the component file, type trpc.notes. and watch the autocomplete. That moment — when your editor knows every backend function and its types without you doing anything — is when tRPC clicks.
FAQ
tRPC is a TypeScript library that lets you call backend functions directly from your frontend with full type safety — no REST endpoints, no API schemas, no code generation. You define a function on the server, and TypeScript instantly knows what arguments it takes and what it returns on the client side.
tRPC is better for full-stack TypeScript apps where the same team controls both frontend and backend. It eliminates boilerplate and catches API errors at compile time. REST is better for public APIs, multi-language teams, or when non-TypeScript clients need to consume your API. They solve different problems.
Yes — it's the most common combination. The @trpc/next and @trpc/react-query packages integrate tRPC directly into Next.js API routes and React components. AI tools frequently generate this pairing because it provides end-to-end type safety with minimal configuration.
GraphQL uses its own schema definition language, requires code generation for type safety, and works with any programming language. tRPC uses your existing TypeScript types directly — no schema files, no code generation, no query language to learn. tRPC is simpler for TypeScript-only teams; GraphQL is more flexible for multi-platform, multi-language APIs.
If you're building full-stack TypeScript apps with Next.js, yes. AI tools increasingly generate tRPC code because it produces less boilerplate. Understanding routers, procedures, and context will help you read, debug, and modify what AI generates — and know when to ask AI for REST instead.