| name | trpc |
| description | tRPC end-to-end type-safe APIs: T3 Stack setup, routers, procedures, Zod validation, React Query integration, middleware, subscriptions |
tRPC Skill
When to activate
- Building the T3 Stack (Next.js + tRPC + Drizzle/Prisma + TypeScript)
- Wanting end-to-end type safety between your Next.js frontend and backend without REST or GraphQL
- Writing type-safe API procedures with Zod input validation
- Setting up tRPC with React Query for data fetching in React
- Adding middleware (auth, rate limiting, logging) to tRPC procedures
When NOT to use
- APIs consumed by non-TypeScript clients (mobile apps, third parties) — use REST instead
- Public APIs — tRPC is designed for same-codebase full-stack TypeScript
- When GraphQL's flexibility is needed — use the GraphQL skill
Instructions
Installation (T3 Stack)
npx create-t3-app@latest my-app
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Project structure
src/
├── server/
│ ├── trpc.ts # tRPC instance, context, middleware
│ ├── routers/
│ │ ├── _app.ts # Root router (combines all routers)
│ │ ├── user.ts # User procedures
│ │ └── post.ts # Post procedures
├── app/
│ └── api/
│ └── trpc/
│ └── [trpc]/
│ └── route.ts # Next.js App Router handler
└── trpc/
└── client.ts # Client-side tRPC setup
tRPC instance setup
import { initTRPC, TRPCError } from '@trpc/server'
import { getServerSession } from 'next-auth'
import type { Session } from 'next-auth'
import superjson from 'superjson'
import { ZodError } from 'zod'
import { db } from '@/db'
export async function createTRPCContext(opts: { headers: Headers }) {
const session = await getServerSession()
return { db, session, headers: opts.headers }
}
type Context = Awaited<ReturnType<typeof createTRPCContext>>
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const router = t.router
export const publicProcedure = t.procedure
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, session: ctx.session } })
})
export const protectedProcedure = t.procedure.use(isAuthed)
const rateLimit = t.middleware(async ({ ctx, next }) => {
const userId = ctx.session?.user?.id ?? ctx.headers.get('x-forwarded-for')
const key = `ratelimit:${userId}`
const count = await redis.incr(key)
if (count === 1) await redis.expire(key, 60)
if (count > 100) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' })
return next()
})
export const rateLimitedProcedure = publicProcedure.use(rateLimit)
Defining procedures (queries and mutations)
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, input.id),
columns: { id: true, name: true, email: true, createdAt: true },
})
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
return user
}),
: protectedProcedure
.(z.({
: z.().(),
: z.().().().().(),
}))
.( ({ ctx, input }) => {
items = ctx....({
: input. + ,
: input. ? (users., input.) : ,
: (users.),
})
hasMore = items. > input.
{
: items.(, input.),
: hasMore ? items[input. - ]. : ,
}
}),
: protectedProcedure
.(z.({
: z.().().().(),
: z.().().(),
}))
.( ({ ctx, input }) => {
[updated] = ctx.
.(users)
.({ ...input, : () })
.((users., ctx...))
.()
updated
}),
: protectedProcedure
.(z.({ : z.().() }))
.( ({ ctx, input }) => {
(ctx... !== input.) {
({ : })
}
ctx..(users).((users., input.))
{ : }
}),
})
Root router
import { router } from '../trpc'
import { userRouter } from './user'
import { postRouter } from './post'
export const appRouter = router({
user: userRouter,
post: postRouter,
})
export type AppRouter = typeof appRouter
Next.js App Router handler
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createTRPCContext } from '@/server/trpc'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ headers: req.headers }),
onError: process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`tRPC error on ${path}:`, error)
: undefined,
})
export { handler as GET, handler as POST }
Client-side setup
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import { trpc } from '@/trpc/client'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
})
)
return (
<trpc.Provider client= =>
{children}
)
}
Usage in React components
'use client'
import { trpc } from '@/trpc/client'
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId })
const updateMutation = trpc.user.update.useMutation({
onSuccess: () => {
trpc.useUtils().user.getById.invalidate({ id: userId })
},
})
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => updateMutation.mutate({ name: 'New Name' })}>
{updateMutation.isPending ? 'Saving...' : 'Update'}
</button>
)
}
Server-side calls (Server Components / Server Actions)
import { createCaller } from '@/server/routers/_app'
import { createTRPCContext } from '@/server/trpc'
export default async function Page() {
const ctx = await createTRPCContext({ headers: new Headers() })
const caller = createCaller(ctx)
const user = await caller.user.getById({ id: '123' })
return <div>{user.name}</div>
}
Example
User: Add a tasks router to a T3 app — create, list (paginated), complete, delete — with authentication required, Zod validation, and a React component that uses all four procedures.
Expected output:
server/routers/task.ts — 4 procedures, all using protectedProcedure
server/routers/_app.ts — updated to include task: taskRouter
components/TaskList.tsx — useQuery for list, useMutation for create/complete/delete, optimistic updates on onMutate