tRPC - Complete Guide to End-to-End Typesafe APIs
What is tRPC?
tRPC is a library that lets you build fully typed APIs without:
- Schemas - no GraphQL schema, OpenAPI spec
- Code generation - no generating clients
- Runtime overhead - types exist only at compile-time
You write functions on the server, and TypeScript automatically infers types on the client. Change a type on the server - TypeScript immediately shows errors on the client.
Why tRPC?
The problem with traditional APIs
Code
TypeScript
// ❌ REST - no type safety
// Server
app.get('/api/users/:id', (req, res) => {
const user = await db.users.findById(req.params.id)
res.json(user)
})
// Client - how do you know the type of user?
const response = await fetch('/api/users/1')
const user = await response.json() // any!Code
TypeScript
// ❌ GraphQL - requires schemas and codegen
// schema.graphql, resolvers, codegen.yml, generated types...
// Lots of configuration and synchronizationThe tRPC solution
Code
TypeScript
// ✅ tRPC - full types automatically
// Server
export const appRouter = router({
users: router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.users.findById(input.id)
// Return type is automatically inferred!
}),
}),
})
// Client - full types without configuration!
const user = await trpc.users.getById.query({ id: '1' })
// user has full type from server
// Autocomplete for user.name, user.email, etc.Installation
Next.js App Router
Code
Bash
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodProject structure
Code
TEXT
src/
├── server/
│ ├── trpc.ts # tRPC initialization
│ ├── routers/
│ │ ├── _app.ts # Root router
│ │ ├── users.ts # Users router
│ │ └── posts.ts # Posts router
│ └── context.ts # Context (auth, db)
├── app/
│ ├── api/trpc/[trpc]/route.ts # API handler
│ └── _trpc/
│ ├── Provider.tsx # React Query provider
│ └── client.ts # tRPC client
└── utils/
└── trpc.ts # Shared typesServer Setup
1. tRPC initialization
TSserver/trpc.ts
TypeScript
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import type { Context } from './context'
const t = initTRPC.context<Context>().create({
transformer: superjson, // Serialization for Date, Map, Set, etc.
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
// Base router and procedure
export const router = t.router
export const publicProcedure = t.procedure
export const createCallerFactory = t.createCallerFactory
// Authentication 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 protectedProcedure = t.procedure.use(isAuthed)
// Admin middleware
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.user?.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({ ctx })
})
export const adminProcedure = protectedProcedure.use(isAdmin)2. Context (auth, db)
TSserver/context.ts
TypeScript
// server/context.ts
import { auth } from '@/lib/auth'
import { db } from '@/db'
export async function createContext() {
const session = await auth()
return {
session,
user: session?.user,
db,
}
}
export type Context = Awaited<ReturnType<typeof createContext>>3. Routers
TSserver/routers/users.ts
TypeScript
// server/routers/users.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure, adminProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
export const usersRouter = router({
// Public procedure - list users
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish(),
}))
.query(async ({ ctx, input }) => {
const users = await ctx.db.users.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
})
let nextCursor: string | undefined
if (users.length > input.limit) {
const nextItem = users.pop()
nextCursor = nextItem?.id
}
return { users, nextCursor }
}),
// Public - single user
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.users.findUnique({
where: { id: input.id },
})
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
})
}
return user
}),
// Protected - current user
me: protectedProcedure
.query(async ({ ctx }) => {
return ctx.db.users.findUnique({
where: { id: ctx.user.id },
include: { posts: true },
})
}),
// Protected - update profile
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
avatar: z.string().url().optional(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.users.update({
where: { id: ctx.user.id },
data: input,
})
}),
// Admin - delete user
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.users.delete({
where: { id: input.id },
})
return { success: true }
}),
})TSserver/routers/posts.ts
TypeScript
// server/routers/posts.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
const createPostSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
slug: z.string().min(1).max(255).regex(/^[a-z0-9-]+$/),
published: z.boolean().default(false),
})
const updatePostSchema = createPostSchema.partial()
export const postsRouter = router({
// List posts (with filtering)
list: publicProcedure
.input(z.object({
status: z.enum(['draft', 'published', 'all']).default('published'),
authorId: z.string().optional(),
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish(),
}))
.query(async ({ ctx, input }) => {
const where: any = {}
if (input.status !== 'all') {
where.status = input.status
}
if (input.authorId) {
where.authorId = input.authorId
}
const posts = await ctx.db.posts.findMany({
where,
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, name: true, avatar: true },
},
},
})
let nextCursor: string | undefined
if (posts.length > input.limit) {
const nextItem = posts.pop()
nextCursor = nextItem?.id
}
return { posts, nextCursor }
}),
// Single post
getBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.posts.findUnique({
where: { slug: input.slug },
include: {
author: true,
comments: {
include: { author: true },
orderBy: { createdAt: 'desc' },
},
},
})
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
})
}
return post
}),
// Create post
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ ctx, input }) => {
// Check if slug is unique
const existing = await ctx.db.posts.findUnique({
where: { slug: input.slug },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Post with this slug already exists',
})
}
return ctx.db.posts.create({
data: {
...input,
authorId: ctx.user.id,
},
})
}),
// Update post
update: protectedProcedure
.input(z.object({
id: z.string(),
data: updatePostSchema,
}))
.mutation(async ({ ctx, input }) => {
// Check ownership
const post = await ctx.db.posts.findUnique({
where: { id: input.id },
})
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
if (post.authorId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return ctx.db.posts.update({
where: { id: input.id },
data: input.data,
})
}),
// Delete post
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.posts.findUnique({
where: { id: input.id },
})
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
if (post.authorId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
await ctx.db.posts.delete({
where: { id: input.id },
})
return { success: true }
}),
// Publish post
publish: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.posts.update({
where: { id: input.id, authorId: ctx.user.id },
data: {
status: 'published',
publishedAt: new Date(),
},
})
}),
})4. Root Router
TSserver/routers/_app.ts
TypeScript
// server/routers/_app.ts
import { router } from '../trpc'
import { usersRouter } from './users'
import { postsRouter } from './posts'
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
})
// Export type for client
export type AppRouter = typeof appRouter5. API Handler (Next.js App Router)
TSapp/api/trpc/[trpc]/route.ts
TypeScript
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`
)
}
: undefined,
})
export { handler as GET, handler as POST }Client Setup
1. tRPC Client
TSapp/_trpc/client.ts
TypeScript
// app/_trpc/client.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()2. Provider
TSapp/_trpc/Provider.tsx
TypeScript
// app/_trpc/Provider.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink, loggerLink } from '@trpc/client'
import { useState } from 'react'
import superjson from 'superjson'
import { trpc } from './client'
function getBaseUrl() {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return `http://localhost:${process.env.PORT ?? 3000}`
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
},
},
})
)
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)
}3. Layout with Provider
TSapp/layout.tsx
TypeScript
// app/layout.tsx
import { TRPCProvider } from './_trpc/Provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
)
}Using on the Client
useQuery
Code
TypeScript
'use client'
import { trpc } from '@/app/_trpc/client'
export function PostsList() {
// Automatic types from the server!
const { data, isLoading, error } = trpc.posts.list.useQuery({
status: 'published',
limit: 10,
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
{data?.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</div>
)
}useInfiniteQuery
Code
TypeScript
'use client'
import { trpc } from '@/app/_trpc/client'
export function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.posts.list.useInfiniteQuery(
{ status: 'published', limit: 10 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
return (
<div>
{data?.pages.map((page) =>
page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))
)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</div>
)
}useMutation
Code
TypeScript
'use client'
import { trpc } from '@/app/_trpc/client'
import { useRouter } from 'next/navigation'
export function CreatePostForm() {
const router = useRouter()
const utils = trpc.useUtils()
const createPost = trpc.posts.create.useMutation({
onSuccess: (post) => {
// Invalidate cache
utils.posts.list.invalidate()
// Redirect
router.push(`/posts/${post.slug}`)
},
onError: (error) => {
alert(error.message)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
slug: formData.get('slug') as string,
})
}}
>
<input name="title" placeholder="Title" required />
<input name="slug" placeholder="slug-url" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}Optimistic Updates
Code
TypeScript
'use client'
import { trpc } from '@/app/_trpc/client'
export function LikeButton({ postId }: { postId: string }) {
const utils = trpc.useUtils()
const likeMutation = trpc.posts.like.useMutation({
onMutate: async ({ postId }) => {
// Cancel ongoing fetches
await utils.posts.getById.cancel({ id: postId })
// Get current data
const previousPost = utils.posts.getById.getData({ id: postId })
// Optimistically update
utils.posts.getById.setData({ id: postId }, (old) => {
if (!old) return old
return {
...old,
likes: old.likes + 1,
isLiked: true,
}
})
return { previousPost }
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousPost) {
utils.posts.getById.setData(
{ id: variables.postId },
context.previousPost
)
}
},
onSettled: () => {
// Refetch to ensure consistency
utils.posts.getById.invalidate({ id: postId })
},
})
return (
<button
onClick={() => likeMutation.mutate({ postId })}
disabled={likeMutation.isPending}
>
❤️ Like
</button>
)
}Server-Side (RSC)
Server Caller
TSserver/caller.ts
TypeScript
// server/caller.ts
import { appRouter } from './routers/_app'
import { createContext } from './context'
import { createCallerFactory } from './trpc'
const createCaller = createCallerFactory(appRouter)
export async function createServerCaller() {
const context = await createContext()
return createCaller(context)
}In Server Components
TSapp/posts/page.tsx
TypeScript
// app/posts/page.tsx
import { createServerCaller } from '@/server/caller'
export default async function PostsPage() {
const trpc = await createServerCaller()
// Call procedure directly on the server
const { posts } = await trpc.posts.list({
status: 'published',
limit: 10,
})
return (
<div>
<h1>Posts</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</div>
)
}Prefetching for Client Components
TSapp/posts/page.tsx
TypeScript
// app/posts/page.tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { createServerCaller } from '@/server/caller'
import { PostsList } from './posts-list'
export default async function PostsPage() {
const trpc = await createServerCaller()
// Prefetch on server
await trpc.posts.list.prefetch({ status: 'published', limit: 10 })
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
)
}Subscriptions (WebSocket)
Setup
TSserver/trpc.ts
TypeScript
// server/trpc.ts
import { applyWSSHandler } from '@trpc/server/adapters/ws'
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 3001 })
applyWSSHandler({
wss,
router: appRouter,
createContext,
})Subscription Procedure
TSserver/routers/messages.ts
TypeScript
// server/routers/messages.ts
import { observable } from '@trpc/server/observable'
import { EventEmitter } from 'events'
const ee = new EventEmitter()
export const messagesRouter = router({
send: protectedProcedure
.input(z.object({
roomId: z.string(),
content: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const message = await ctx.db.messages.create({
data: {
...input,
authorId: ctx.user.id,
},
include: { author: true },
})
ee.emit(`room:${input.roomId}`, message)
return message
}),
onMessage: protectedProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<Message>((emit) => {
const handler = (message: Message) => {
emit.next(message)
}
ee.on(`room:${input.roomId}`, handler)
return () => {
ee.off(`room:${input.roomId}`, handler)
}
})
}),
})Client
Code
TypeScript
'use client'
import { trpc } from '@/app/_trpc/client'
export function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([])
trpc.messages.onMessage.useSubscription(
{ roomId },
{
onData: (message) => {
setMessages((prev) => [...prev, message])
},
}
)
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>{msg.content}</div>
))}
</div>
)
}Error Handling
Custom Errors
Code
TypeScript
import { TRPCError } from '@trpc/server'
// Throwing errors
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid input',
cause: originalError,
})
// Available codes:
// 'PARSE_ERROR' - 400
// 'BAD_REQUEST' - 400
// 'UNAUTHORIZED' - 401
// 'FORBIDDEN' - 403
// 'NOT_FOUND' - 404
// 'METHOD_NOT_SUPPORTED' - 405
// 'TIMEOUT' - 408
// 'CONFLICT' - 409
// 'PRECONDITION_FAILED' - 412
// 'PAYLOAD_TOO_LARGE' - 413
// 'UNPROCESSABLE_CONTENT' - 422
// 'TOO_MANY_REQUESTS' - 429
// 'CLIENT_CLOSED_REQUEST' - 499
// 'INTERNAL_SERVER_ERROR' - 500
// 'NOT_IMPLEMENTED' - 501
// 'BAD_GATEWAY' - 502
// 'SERVICE_UNAVAILABLE' - 503
// 'GATEWAY_TIMEOUT' - 504Error Formatting
TSserver/trpc.ts
TypeScript
// server/trpc.ts
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Add Zod errors
zodError:
error.cause instanceof ZodError
? error.cause.flatten()
: null,
// Add custom field
code: error.code,
},
}
},
})Client-side error handling
Code
TypeScript
const createPost = trpc.posts.create.useMutation({
onError: (error) => {
if (error.data?.zodError) {
// Validation errors
const fieldErrors = error.data.zodError.fieldErrors
setErrors(fieldErrors)
} else if (error.data?.code === 'CONFLICT') {
// Conflict error
toast.error('Post with this slug already exists')
} else {
// Generic error
toast.error(error.message)
}
},
})Middleware
Logging
Code
TypeScript
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now()
const result = await next()
const duration = Date.now() - start
console.log(`${type} ${path} - ${duration}ms`)
return result
})Rate Limiting
Code
TypeScript
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
})
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const identifier = ctx.user?.id || ctx.ip || 'anonymous'
const { success } = await ratelimit.limit(identifier)
if (!success) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS' })
}
return next()
})
export const rateLimitedProcedure = publicProcedure.use(rateLimitMiddleware)Caching
Code
TypeScript
const cacheMiddleware = t.middleware(async ({ path, next, ctx }) => {
const cacheKey = `trpc:${path}`
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
const result = await next()
await redis.setex(cacheKey, 60, JSON.stringify(result))
return result
})Testing
Unit Testing Procedures
TS__tests__/posts.test.ts
TypeScript
// __tests__/posts.test.ts
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
import { createCallerFactory } from '@/server/trpc'
const createCaller = createCallerFactory(appRouter)
describe('Posts Router', () => {
it('should create a post', async () => {
const ctx = await createContext()
const caller = createCaller({
...ctx,
user: { id: '1', role: 'user' },
})
const post = await caller.posts.create({
title: 'Test Post',
content: 'Content',
slug: 'test-post',
})
expect(post.title).toBe('Test Post')
expect(post.authorId).toBe('1')
})
it('should throw UNAUTHORIZED for unauthenticated users', async () => {
const ctx = await createContext()
const caller = createCaller({ ...ctx, user: null })
await expect(
caller.posts.create({
title: 'Test',
content: 'Content',
slug: 'test',
})
).rejects.toThrow('UNAUTHORIZED')
})
})Best Practices
1. Router organization
Code
TypeScript
// Group by domain
const appRouter = router({
users: usersRouter,
posts: postsRouter,
comments: commentsRouter,
auth: authRouter,
admin: adminRouter,
})2. Shared schemas
TSshared/schemas.ts
TypeScript
// shared/schemas.ts
import { z } from 'zod'
export const paginationSchema = z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish(),
})
export const createPostSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
slug: z.string().min(1).max(255),
})
// In the router
.input(paginationSchema)
.input(createPostSchema)3. Reusable procedures
TSserver/procedures.ts
TypeScript
// server/procedures.ts
export const paginatedProcedure = publicProcedure.input(paginationSchema)
export const ownerProcedure = protectedProcedure.use(async ({ ctx, next, input }) => {
const resource = await ctx.db.findResource(input.id)
if (resource.ownerId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({ ctx: { ...ctx, resource } })
})tRPC vs Alternatives
| Feature | tRPC | GraphQL | REST |
|---|---|---|---|
| Type safety | ✅ Auto | ⚠️ Codegen | ❌ Manual |
| Bundle size | Small | Medium | Small |
| Learning curve | Easy | Medium | Easy |
| Overfetching | ❌ | ❌ | ✅ |
| Caching | React Query | Apollo | Manual |
| Schema | ❌ Not needed | ✅ Required | ⚠️ OpenAPI |
FAQ
When tRPC vs GraphQL?
tRPC when:
- Fullstack TypeScript (monorepo or same repo)
- Small/medium team
- Fast development
GraphQL when:
- Multiple clients (mobile, web, partners)
- Different languages (not just TS)
- Need schema as contract
Can I use tRPC with REST API?
Yes! tRPC can coexist with REST. Use tRPC for new endpoints, REST for legacy.
How to test?
Use createCaller for unit tests. For E2E tests - regular HTTP requests.
Summary
tRPC is a game-changer for fullstack TypeScript:
- Zero boilerplate - no schemas, codegen
- Full types - end-to-end type safety
- React Query - built-in integration
- Validation - Zod on server
- Middleware - auth, rate limiting, caching
- SSR ready - works with Next.js
If you're building a fullstack TypeScript app - tRPC is a must-have.