Skip to main content
โšก Calmops

tRPC Complete Guide: End-to-End Type-Safe APIs

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.


External Resources

Comments