Introduction
tRPC enables you to build type-safe APIs without any schema definition. It automatically infers TypeScript types on the client from the server code. This guide covers everything you need to build end-to-end type-safe applications with tRPC.
Understanding tRPC
What is tRPC?
tRPC creates a type-safe layer between your frontend and backend:
- No API schema to maintain
- Types automatically shared
- Full TypeScript inference
- Works with any framework
// Server - Define procedure
const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } })
}),
})
// Client - Use with full type inference!
const user = await trpc.getUser.query({ id: '123' })
// user is fully typed!
Why tRPC?
| Feature | tRPC | REST | GraphQL |
|---|---|---|---|
| Type Safety | Automatic | Manual | Manual |
| Schema | None | OpenAPI | SDL |
| Learning Curve | Low | Medium | High |
| Bundle Size | Small | N/A | Large |
| Caching | Manual | HTTP | Built-in |
Getting Started
Installation
# Install tRPC and dependencies
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Basic Setup
// src/server/trpc.ts
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure
// src/server/index.ts
import { router, publicProcedure } from './trpc'
import { z } from 'zod'
export const appRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return `Hello, ${input.name}!`
}),
})
export type AppRouter = typeof appRouter
Client Setup
// src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server'
export const trpc = createTRPCReact<AppRouter>()
// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { trpc } from './utils/trpc'
export function App() {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<YourComponents />
</QueryClientProvider>
</trpc.Provider>
)
}
Procedures
Query Procedure
// Simple query
const getUser = publicProcedure.query(async () => {
return db.user.findMany()
})
// With input validation
const getUserById = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({
where: { id: input.id }
})
})
// With async input
const getPosts = publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish(),
}))
.query(async ({ input }) => {
return db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
})
})
Mutation Procedure
// Create mutation
const createUser = publicProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.user.create({
data: input,
})
})
// Update mutation
const updateUser = publicProcedure
.input(z.object({
id: z.string(),
data: z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
}),
}))
.mutation(async ({ input }) => {
return db.user.update({
where: { id: input.id },
data: input.data,
})
})
// Delete mutation
const deleteUser = publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
return db.user.delete({
where: { id: input.id },
})
})
Authentication
Protected Procedures
// src/server/context.ts
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await getSession(opts.req.headers.get('authorization'))
return {
session,
db,
}
}
type Context = Awaited<ReturnType<typeof createContext>>
// src/server/trpc.ts
const t = initTRPC.context<Context>().create()
export const router = t.router
export const publicProcedure = t.procedure
// Protected procedure
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({
ctx: {
...ctx,
session: ctx.session,
},
})
})
// Use protected procedure
const getProfile = protectedProcedure.query(async ({ ctx }) => {
return ctx.db.user.findUnique({
where: { id: ctx.session.user.id },
})
})
Error Handling
import { TRPCError } from '@trpc/server'
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
})
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
})
}
return user
})
Error Handling on Client
import { trpc } from './utils/trpc'
function Profile() {
const { data, error } = trpc.getUser.useQuery({ id: '123' })
if (error) {
return <div>Error: {error.message}</div>
}
return <div>{data?.name}</div>
}
React Query Integration
Basic Query
import { trpc } from './utils/trpc'
function UserList() {
const { data, isLoading, error } = trpc.getUsers.useQuery()
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Mutations
import { trpc } from './utils/trpc'
function CreateUser() {
const utils = trpc.useUtils()
const { mutate, isLoading } = trpc.createUser.useMutation({
onSuccess: () => {
utils.getUsers.invalidate()
},
})
const handleSubmit = (e) => {
e.preventDefault()
mutate({ name: 'John', email: '[email protected]' })
}
return (
<form onSubmit={handleSubmit}>
<button disabled={isLoading}>Create</button>
</form>
)
}
Optimistic Updates
const updateName = trpc.updateUser.useMutation({
onMutate: async (newData) => {
await utils.getUser.cancel()
const previous = utils.getUser.getData({ id: newData.id })
utils.getUser.setData({ id: newData.id }, (old) => ({
...old,
name: newData.name,
}))
return { previous }
},
onError: (err, newData, context) => {
utils.getUser.setData(
{ id: newData.id },
context?.previous
)
},
})
With Next.js
App Router
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server'
import { createContext } from '@/server/context'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
})
export { handler as GET, handler as POST }
Server Actions Alternative
// src/server/index.ts
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure
export const createCallerFactory = t.createCallerFactory
// src/app/actions.ts
'use server'
import { createCallerFactory } from '@/server'
import { appRouter } from '@/server'
const createCaller = createCallerFactory(appRouter)
export async function getUser(id: string) {
const caller = await createCaller({})
return caller.getUser({ id })
}
tRPC vs GraphQL
| Feature | tRPC | GraphQL |
|---|---|---|
| Schema | None | SDL required |
| Types | Automatic | Manual |
| Network | HTTP | HTTP |
| Caching | React Query | Custom |
| Learning | Low | High |
Best Practices
1. Use Zod for Validation
// โ
Good - explicit validation
const createUser = publicProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().min(0).optional(),
}))
.mutation(...)
2. Organize Routers
// src/server/routers/users.ts
export const usersRouter = router({
list: publicProcedure.query(...),
byId: publicProcedure.input(z.string()).query(...),
create: publicProcedure.input(...).mutation(...),
})
// src/server/routers/posts.ts
export const postsRouter = router({
list: publicProcedure.query(...),
byId: publicProcedure.input(z.string()).query(...),
})
// src/server/index.ts
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
})
3. Use Protected Procedures for Auth
// Always validate auth in procedures
const getSettings = protectedProcedure.query(({ ctx }) => {
return ctx.db.settings.findUnique({
where: { userId: ctx.session.user.id },
})
})
Conclusion
tRPC is excellent when you:
- Use TypeScript throughout your app
- Want zero schema maintenance
- Need simple, type-safe APIs
- Prefer React Query for data fetching
Perfect for: TypeScript monorepos, Next.js apps, full-stack TypeScript projects.
Comments