Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds
Torna alle collezioni
Guide16 min read

tRPC

tRPC is a library for building end-to-end typesafe APIs in TypeScript. No schemas, no code generation - types are automatically shared between server and client.

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 synchronization

The 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 zod

Project 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 types

Server 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 appRouter

5. 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' - 504

Error 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

FeaturetRPCGraphQLREST
Type safety✅ Auto⚠️ Codegen❌ Manual
Bundle sizeSmallMediumSmall
Learning curveEasyMediumEasy
Overfetching
CachingReact QueryApolloManual
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.