Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Learn how tRPC eliminates the need for API schemas by leveraging TypeScript's type system. Build end-to-end type-safe APIs with automatic client generation.
// table of contents (30 sections)
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
If you’ve ever built a full-stack TypeScript application, you’ve probably experienced the “type drift” problem: your backend types and frontend types slowly diverge, leading to runtime bugs that TypeScript couldn’t catch. tRPC solves this elegantly by sharing types directly between client and server—no code generation, no schemas, just pure TypeScript.
For the broader context on modern frontend stacks, see my guide on the modern frontend stack of 2026.
The Problem tRPC Solves
Traditional API Development
Backend Frontend
├── Define types ├── Fetch API
├── Write handlers ├── Guess response types
├── Create OpenAPI spec ├── Generate types from spec
└── Deploy └── Pray they match
The Pain Points:
- Schema maintenance: OpenAPI/Swagger specs need constant updates
- Code generation lag: Generated types are always one step behind
- Runtime mismatches: Types compile but API returns unexpected data
- Duplication: Same types defined in multiple places
The tRPC Approach
Backend + Frontend (Shared TypeScript Project)
├── Define router with types
├── Export type inference
├── Client auto-completes everything
└── Zero runtime surprises
How tRPC Works
1. Define Your Router (Backend)
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
// Query: Get user by ID
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({
where: { id: input.id }
});
return user;
}),
// Mutation: Create user
create: publicProcedure
.input(z.object({
name: z.string(),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
const user = await db.user.create({ data: input });
return user;
}),
// Subscription: Real-time updates
onUpdate: publicProcedure
.subscription(() => {
return observable<User>((emit) => {
const subscription = db.user.onUpdate((user) => {
emit.next(user);
});
return () => subscription.unsubscribe();
});
}),
});
export type UserRouter = typeof userRouter;
2. Create the API Server
// server/index.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { userRouter } from './routers/user';
const appRouter = userRouter;
export type AppRouter = typeof appRouter;
createHTTPServer({
router: appRouter,
}).listen(3000);
3. Consume from Frontend
// client/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/index';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000',
}),
],
});
// Full autocomplete and type safety!
const user = await client.getById.query({ id: '123' });
// ^? { id: string; name: string; email: string; } | null
const newUser = await client.create.mutate({
name: 'Abdu',
email: 'abdu@example.com'
});
// Full type checking on input!
Key Features in 2026
Superjson for Rich Types
tRPC now uses Superjson by default, enabling:
// Dates, Maps, Sets, and more work seamlessly
const router = t.router({
getMetrics: publicProcedure.query(() => ({
timestamp: new Date(),
metadata: new Map([['version', '2.0']]),
tags: new Set(['production', 'stable']),
})),
});
// Client receives actual Date/Map/Set objects, not JSON strings!
const metrics = await client.getMetrics.query();
console.log(metrics.timestamp instanceof Date); // true
Automatic Request Batching
tRPC batches multiple requests into a single HTTP call:
// These three calls become ONE HTTP request
const [user, posts, comments] = await Promise.all([
client.user.getById.query({ id: '1' }),
client.posts.list.query({ userId: '1' }),
client.comments.list.query({ userId: '1' }),
]);
Built-in Error Handling
// server/trpc.ts
import { TRPCError } from '@trpc/server';
export const protectedProcedure = t.procedure
.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}
return next();
});
// client gets typed errors
try {
await client.admin.deleteUser.mutate({ id: '1' });
} catch (error) {
if (error instanceof TRPCClientError) {
// Full type info on error
console.log(error.data.code); // 'UNAUTHORIZED'
}
}
tRPC vs Alternatives
| Feature | tRPC | GraphQL | REST + OpenAPI |
|---|---|---|---|
| Type Safety | Automatic | Codegen | Codegen |
| Schema Required | No | Yes | Yes |
| Learning Curve | Low | Medium | Low |
| Bundle Size | Small | Large | Small |
| Browser Support | Full | Full | Full |
| Real-time | Built-in | Subscriptions | Separate impl |
When to Use tRPC
Best For:
- Full-stack TypeScript projects
- Monorepos with shared types
- Teams that want fast iteration
- Real-time features needed
Not Ideal For:
- Public APIs (external consumers)
- Non-TypeScript clients
- Microservices with different languages
Real-World Example: Building a Task Manager
Backend Setup
// server/routers/task.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const taskRouter = router({
list: publicProcedure
.input(z.object({
status: z.enum(['todo', 'in_progress', 'done']).optional(),
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
}))
.query(async ({ input }) => {
const tasks = await db.task.findMany({
where: input.status ? { status: input.status } : undefined,
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined;
if (tasks.length > input.limit) {
const nextItem = tasks.pop();
nextCursor = nextItem!.id;
}
return { tasks, nextCursor };
}),
create: publicProcedure
.input(z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
}))
.mutation(async ({ input }) => {
return db.task.create({
data: {
...input,
status: 'todo',
},
});
}),
update: publicProcedure
.input(z.object({
id: z.string(),
data: z.object({
title: z.string().min(1).max(200).optional(),
status: z.enum(['todo', 'in_progress', 'done']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
}),
}))
.mutation(async ({ input }) => {
return db.task.update({
where: { id: input.id },
data: input.data,
});
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
await db.task.delete({ where: { id: input.id } });
return { success: true };
}),
});
export type TaskRouter = typeof taskRouter;
Frontend Usage with React
// client/App.tsx
import { trpc } from './trpc';
function TaskList() {
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>();
// Queries auto-refetch and cache intelligently
const { data, isLoading, fetchNextPage, hasNextPage } =
trpc.task.list.useInfiniteQuery(
{ status, limit: 20 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
const createMutation = trpc.task.create.useMutation({
onSuccess: () => {
// Auto-invalidate and refetch
utils.task.list.invalidate();
},
});
const updateMutation = trpc.task.update.useMutation();
if (isLoading) return <div>Loading...</div>;
return (
<div>
<select onChange={(e) => setStatus(e.target.value as any)}>
<option value="">All</option>
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
{data?.pages.flatMap((page) => page.tasks).map((task) => (
<div key={task.id}>
<h3>{task.title}</h3>
<select
value={task.status}
onChange={(e) => updateMutation.mutate({
id: task.id,
data: { status: e.target.value as any },
})}
>
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load More</button>
)}
</div>
);
}
Integration with Popular Frameworks
Next.js App Router
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Astro Integration
tRPC works great with Astro for building dynamic features. See my post on the modern frontend stack for how Astro + React combine beautifully.
// src/pages/api/trpc/[...trpc].ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
export const ALL = ({ request }: APIContext) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req: request,
router: appRouter,
});
Performance Tips
1. Use Batching Wisely
// Bad: Sequential requests
const user = await client.user.getById.query({ id: '1' });
const posts = await client.post.list.query({ userId: '1' });
// Good: Parallel requests (auto-batched)
const [user, posts] = await Promise.all([
client.user.getById.query({ id: '1' }),
client.post.list.query({ userId: '1' }),
]);
2. Enable Caching
const utils = trpc.useUtils();
// Cache the result
await utils.user.getById.prefetch({ id: '1' });
// Later, this returns instantly
const user = await client.user.getById.query({ id: '1' });
3. Optimize with Middleware
// Add caching middleware for expensive queries
import { initTRPC } from '@trpc/server';
import { cache } from 'react';
const t = initTRPC.create();
export const cachedProcedure = t.procedure
.use(async ({ path, next }) => {
// Cache GET-like queries
if (path.includes('.query')) {
return cache(() => next())();
}
return next();
});
Security Best Practices
Rate Limiting
For API protection, see my guide on rate limiting strategies for APIs.
import { Ratelimit } from '@unkey/ratelimit';
const limiter = new Ratelimit({
redis: redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
export const rateLimitedProcedure = publicProcedure
.use(async ({ ctx, next }) => {
const { success } = await limiter.limit(ctx.ip);
if (!success) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
return next();
});
Input Validation
Zod schemas provide runtime validation:
const createPostSchema = z.object({
title: z.string()
.min(5, 'Title must be at least 5 characters')
.max(200, 'Title too long'),
content: z.string()
.min(100, 'Content too short')
.refine(
(val) => !containsSpam(val),
'Content contains forbidden words'
),
tags: z.array(z.string()).max(5, 'Maximum 5 tags'),
});
Common Pitfalls
1. Circular Dependencies
// Bad: Circular type reference
type User = {
posts: Post[];
};
type Post = {
author: User; // Circular!
};
// Good: Use type references
type User = {
posts: Post[];
};
type Post = {
authorId: string;
};
2. Over-fetching
// Bad: Returns entire user object
getUser: publicProcedure.query(() => db.user.findMany()),
// Good: Select only needed fields
getUser: publicProcedure
.output(z.array(z.object({
id: z.string(),
name: z.string(),
})))
.query(() => db.user.findMany({
select: { id: true, name: true },
})),
Conclusion
tRPC represents a paradigm shift in API development for TypeScript teams. By eliminating the gap between frontend and backend types, it removes an entire class of bugs while dramatically improving developer experience.
Key Benefits:
- Zero-config type safety: Types flow from server to client automatically
- No schema maintenance: Your TypeScript types ARE the schema
- Excellent DX: Autocomplete for everything, errors at compile time
- Production-ready: Used by major companies at scale
When to Adopt:
- If you’re building a TypeScript monorepo
- If your frontend and backend teams are the same people
- If you want to move fast without sacrificing type safety
Next Steps:
- Check out the official tRPC docs
- Learn about building REST APIs with Go for microservices
- Explore AI API integration patterns for modern AI features
Are you using tRPC in your projects? Connect with me on Twitter to discuss your experience!
You might also like
Rate Limiting Strategies for APIs: Protect Your Backend in 2026
Master API rate limiting with practical strategies and implementations. Compare token bucket, sliding window, and fixed window algorithms with real code examples in Go, Node.js, and Redis.
Building Resilient APIs: Circuit Breakers, Retries, and Rate Limiting in Production
Master production-ready API resilience with circuit breakers, exponential backoff, rate limiting, and fallback strategies. Includes TypeScript examples and real-world patterns.
Structured Outputs in LLMs — Getting Reliable JSON from AI
Stop parsing unpredictable LLM responses. Learn how to use structured outputs with JSON Schema, tool calls, and provider-specific features to get reliable, typed data from AI models.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Building Resilient APIs: Circuit Breakers, Retries, and Rate Limiting in Production
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
