trpc-best-practices
Skill0 starsChanged 3 months agoMITView on GitHub
Build end-to-end typesafe APIs with tRPC, Zod validation, and Next.js integration for full-stack TypeScript applications
- Reads credentials
- Installs packages
In this repository27 other files it gives its agents.
- architecture-designerSkill
- architecture-patternsSkill
- code-reviewSkill
- dockerSkill
- fastapiSkill
- feature-specificationSkill
- github-issue-resolverSkill
- github-opsSkill
- kubernetes-devopsSkill
- monitoringSkill
- nextjsSkill
- nginxSkill
- prd-to-ddd-designSkill
- prd-writerSkill
- prismaSkill
- react-expertSkill
- redisSkill
- redis-storeSkill
- requirements-analysisSkill
- shadcn-uiSkill
- ssh-essentialsSkill
- task-decomposerSkill
- task-development-workflowSkill
- typescript-proSkill
- typescriptSkill
- webhookSkill
- zodSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 5 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 26
Use this skill when:
- Distinctive: line 36
Don't use: when you need:
- Distinctive: line 86
Karpathy Principle: Simplicity First: - Start with a single router file.
- Very specific: line 233
Karpathy Principle: Goal-Driven Execution: - After setup, verify type inference works: hover over hello.data - you should see { greeting: …
- Very specific: line 386
Karpathy Principle: Surgical Changes: - Add auth middleware once, reuse everywhere.
- Distinctive: line 737
Before deploying tRPC APIs:
- Distinctive: line 772
Use Prisma types for input/output validation
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
tRPC - End-to-End Typesafe APIs
When to Use
Use this skill when:
- Building full-stack TypeScript applications (Next.js, React, Vue)
- Need complete type safety from server to client
- Want to avoid code generation or GraphQL complexity
- Integrating with React Query for data fetching
- Building internal APIs (not public REST APIs)
- Rapid prototyping with type safety guarantees
- Monorepo setups with shared types
Don't use when you need:
- Public REST APIs for third-party consumption
- Non-TypeScript clients
- GraphQL features (federation, schema stitching)
- Microservices with different languages
- Maximum backwards compatibility (tRPC couples client/server tightly)
Karpathy Principle: Think Before Coding - tRPC couples your client and server. Plan your API surface carefully - breaking changes affect both sides simultaneously. Consider versioning strategies early.
Karpathy Principle: Trade-offs Everywhere - tRPC trades broad compatibility (REST works everywhere) for developer velocity (types flow automatically). Choose tRPC for internal tools, choose REST for public APIs. You can't have both.
Prerequisites
Required Knowledge
- TypeScript fundamentals (types, interfaces, generics, inference)
- React Query basics (useQuery, useMutation, cache invalidation)
- Next.js API routes (or your chosen backend framework)
- Zod validation schemas
Required Tools
# Core tRPC packages
npm install @trpc/server @trpc/client @trpc/react-query
# Peer dependencies
npm install @tanstack/react-query zod
# Optional but recommended
npm install superjson # For Date, Map, Set serializationProject Structure (Next.js App Router)
src/
├── server/
│ ├── trpc.ts # tRPC instance + procedures
│ ├── context.ts # Request context
│ └── routers/
│ ├── _app.ts # Root router
│ ├── user.ts # User procedures
│ └── post.ts # Post procedures
├── lib/
│ └── trpc.ts # Client setup
├── app/
│ ├── api/trpc/[trpc]/
│ │ └── route.ts # API route handler
│ ├── providers.tsx # tRPC + React Query provider
│ └── page.tsx # Usage exampleKarpathy Principle: Simplicity First - Start with a single router file. Split into multiple routers only when you have 15+ procedures. Premature organization creates complexity.
Karpathy Principle: Fail Fast, Fail Loud - tRPC's type errors at compile time are a feature, not a bug. Don't silence them with any. If client/server types disagree, you have a real problem that would surface as runtime errors in REST.
Core Workflows
1. Initial Setup (Next.js App Router)
Step 1: Create tRPC instance
// src/server/trpc.ts
import { initTRPC } from '@trpc/server'
import SuperJSON from 'superjson'
export const t = initTRPC.create({
transformer: SuperJSON, // Serialize Dates, Maps, Sets
})
export const router = t.router
export const publicProcedure = t.procedureStep 2: Create context
// src/server/context.ts
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import { getServerSession } from 'next-auth'
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await getServerSession()
return {
session,
db, // Prisma client
}
}
export type Context = Awaited<ReturnType<typeof createContext>>Step 3: Create root router
// src/server/routers/_app.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 { greeting: `Hello ${input.name}!` }
}),
})
export type AppRouter = typeof appRouterStep 4: Create API route
// src/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,
})
export { handler as GET, handler as POST }Step 5: Setup client
// src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()Step 6: Add provider
// src/app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc'
import SuperJSON from 'superjson'
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 Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
},
},
}))
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON,
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)
}Step 7: Use in components
'use client'
import { trpc } from '@/lib/trpc'
export default function HomePage() {
const hello = trpc.hello.useQuery({ name: 'World' })
if (hello.isLoading) return <div>Loading...</div>
if (hello.error) return <div>Error: {hello.error.message}</div>
return <h1>{hello.data.greeting}</h1>
}Karpathy Principle: Goal-Driven Execution - After setup, verify type inference works: hover over hello.data - you should see { greeting: string } with no manual typing. If not, something is misconfigured.
2. Queries and Mutations
Queries (fetch data):
export const userRouter = router({
getById: publicProcedure
.input(z.string())
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input }
})
if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
return user
}),
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.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
})
let nextCursor: string | undefined
if (users.length > input.limit) {
const nextItem = users.pop()
nextCursor = nextItem!.id
}
return { users, nextCursor }
}),
})Mutations (create/update/delete):
export const userRouter = router({
create: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.create({ data: input })
return user
}),
update: publicProcedure
.input(z.object({
id: z.string(),
name: z.string().min(2).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return await ctx.db.user.update({
where: { id },
data,
})
}),
delete: publicProcedure
.input(z.string())
.mutation(async ({ ctx, input }) => {
await ctx.db.user.delete({ where: { id: input } })
return { success: true }
}),
})Client usage:
'use client'
import { trpc } from '@/lib/trpc'
export function UserList() {
const utils = trpc.useUtils()
const users = trpc.user.list.useQuery({ limit: 10 })
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.list.invalidate() // Refetch list
},
})
return (
<div>
{users.data?.users.map(user => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={() => createUser.mutate({
name: 'John Doe',
email: 'john@example.com'
})}>
Add User
</button>
</div>
)
}3. Protected Procedures with Middleware
// src/server/trpc.ts
import { TRPCError } from '@trpc/server'
const isAuthed = t.middleware(async ({ 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)Usage:
export const postRouter = router({
// Public - anyone can read
list: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.post.findMany()
}),
// Protected - must be logged in
create: protectedProcedure
.input(z.object({
title: z.string(),
content: z.string(),
}))
.mutation(async ({ ctx, input }) => {
return await ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id, // ctx.user guaranteed by middleware
},
})
}),
})Karpathy Principle: Surgical Changes - Add auth middleware once, reuse everywhere. Don't check ctx.session in every procedure - that's fragile and repetitive.
6. Procedure Composition (Advanced)
Extract reusable logic from procedures using inferProcedureBuilderResolverOptions:
import { inferProcedureBuilderResolverOptions } from '@trpc/server'
// Generic helper function that works with any procedure builder
async function logActivity<T extends ProcedureBuilder<any>>(
opts: inferProcedureBuilderResolverOptions<T>,
action: string,
) {
const { ctx, input } = opts
await ctx.db.activityLog.create({
data: {
userId: ctx.user?.id,
action,
input: JSON.stringify(input),
},
})
}
// Use in multiple procedures
export const userRouter = router({
create: protectedProcedure
.input(createUserSchema)
.mutation(async (opts) => {
await logActivity(opts, 'user.create') // ← Reusable
return await opts.ctx.db.user.create({ data: opts.input })
}),
delete: protectedProcedure
.input(z.string())
.mutation(async (opts) => {
await logActivity(opts, 'user.delete') // ← Same helper
return await opts.ctx.db.user.delete({ where: { id: opts.input } })
}),
})Karpathy Principle: Composition Over Duplication - Extract reusable procedure logic into typed helper functions. Middleware is for cross-cutting concerns (auth, logging). Helpers are for shared business logic.
Common Patterns
1. Nested Routers (Organization)
// src/server/routers/user.ts
export const userRouter = router({
getById: publicProcedure.input(z.string()).query(...),
create: protectedProcedure.input(...).mutation(...),
})
// src/server/routers/post.ts
export const postRouter = router({
list: publicProcedure.query(...),
create: protectedProcedure.mutation(...),
})
// src/server/routers/_app.ts
export const appRouter = router({
user: userRouter,
post: postRouter,
})
// Client usage
trpc.user.getById.useQuery('123')
trpc.post.list.useQuery()2. Input Validation with Zod
const createPostInput = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
tags: z.array(z.string()).max(5).optional(),
publishedAt: z.date().optional(),
})
export const postRouter = router({
create: protectedProcedure
.input(createPostInput)
.mutation(async ({ ctx, input }) => {
// input is fully typed and validated
return await ctx.db.post.create({ data: input })
}),
})3. Optimistic Updates
const updatePost = trpc.post.update.useMutation({
onMutate: async (newPost) => {
await utils.post.getById.cancel({ id: newPost.id })
const previousPost = utils.post.getById.getData({ id: newPost.id })
utils.post.getById.setData({ id: newPost.id }, (old) => ({
...old!,
...newPost,
}))
return { previousPost }
},
onError: (err, newPost, context) => {
utils.post.getById.setData(
{ id: newPost.id },
context?.previousPost
)
},
onSettled: (data, error, variables) => {
utils.post.getById.invalidate({ id: variables.id })
},
})4. Infinite Queries (Pagination)
const posts = trpc.post.list.useInfiniteQuery(
{ limit: 10 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
return (
<div>
{posts.data?.pages.map((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
<button
onClick={() => posts.fetchNextPage()}
disabled={!posts.hasNextPage}
>
Load More
</button>
</div>
)5. Subscriptions (WebSocket Real-Time)
When to use: Real-time features (chat, notifications, live data)
npm install ws @trpc/server// server/trpc.ts
import { observable } from '@trpc/server/observable'
import { EventEmitter } from 'events'
const ee = new EventEmitter()
export const postRouter = router({
onAdd: publicProcedure.subscription(() => {
return observable<Post>((emit) => {
const onAdd = (data: Post) => emit.next(data)
ee.on('add', onAdd)
return () => ee.off('add', onAdd) // Cleanup
})
}),
add: publicProcedure
.input(createPostSchema)
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.create({ data: input })
ee.emit('add', post) // Trigger subscription
return post
}),
})// Client usage
const posts = trpc.post.onAdd.useSubscription(undefined, {
onData(post) {
// New post received
console.log('New post:', post)
},
})Note: Subscriptions require WebSocket server setup (see tRPC docs).
6. Server-Side Calls (RSC, API Routes)
// In Server Components
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
export default async function Page() {
const ctx = await createContext({ req: new Request('...') })
const caller = appRouter.createCaller(ctx)
const users = await caller.user.list({ limit: 10 })
return <UserList users={users.users} />
}Common Pitfalls
1. Missing Transformer (Dates Break)
❌ Bad: Dates become strings on client
Cause: No transformer configured
✅ Good:
// Server
export const t = initTRPC.create({
transformer: SuperJSON, // ← Add this
})
// Client
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: SuperJSON, // ← And this
}),
],
})2. Context Type Errors
❌ Bad: ctx has wrong type in procedures
Cause: Not exporting Context type from context file
✅ Good:
// src/server/context.ts
export async function createContext(opts) {
return { db, session }
}
export type Context = Awaited<ReturnType<typeof createContext>> // ← Export this
// src/server/trpc.ts
import type { Context } from './context'
export const t = initTRPC.context<Context>().create() // ← Use it here3. React Query Cache Not Updating
❌ Bad: Mutation succeeds but UI doesn't update
Cause: Forgot to invalidate queries
✅ Good:
const utils = trpc.useUtils() // ← Get utils
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.list.invalidate() // ← Invalidate related queries
},
})4. tRPC Not Found in Client
❌ Bad: trpc.user.list.useQuery is not a function
Cause: Provider not wrapping app or wrong import
✅ Good:
// layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers> {/* ← Must wrap */}
</body>
</html>
)
}5. Batching Not Working
❌ Bad: Multiple queries firing separately
Cause: Using httpLink instead of httpBatchLink
✅ Good:
import { httpBatchLink } from '@trpc/client' // ← Use batch link
trpc.createClient({
links: [
httpBatchLink({ url: '/api/trpc' }), // ← Batches requests
],
})6. Procedure Not Found After Adding
❌ Bad: trpc.user.create is undefined after adding procedure
Cause: AppRouter type not exported or client not restarted
✅ Good:
// server/routers/_app.ts
export const appRouter = router({ ... })
export type AppRouter = typeof appRouter // ← Must export type
// Restart dev server after adding procedures7. Input Validation Bypassed
❌ Bad: Invalid data reaches mutation handler
Cause: Destructuring input before validation runs
✅ Good:
// Wrong
const create = publicProcedure
.mutation(async ({ input }) => { // ← input not validated yet!
return db.create(input)
})
.input(schema) // ← Must come BEFORE mutation
// Correct
const create = publicProcedure
.input(schema) // ← Validate first
.mutation(async ({ input }) => { // ← input is validated
return db.create(input)
})Verification Checklist
Before deploying tRPC APIs:
Type Safety
- Hover over client calls - see full response types
- Input validation errors caught at compile time
- No
anytypes in procedure handlers - Context type exported and used correctly
Performance
- Using
httpBatchLinkfor batching - Query invalidation working (UI updates after mutations)
- Caching configured (staleTime, cacheTime)
- Transformer configured if using Dates/Maps/Sets
Error Handling
- Auth errors throw
UNAUTHORIZEDcode - Not found errors throw
NOT_FOUNDcode - Validation errors show field-specific messages
- Error boundaries catch tRPC errors
Security
- Protected procedures use middleware (not manual checks)
- Input validation on all procedures
- SQL injection prevented (using Prisma/ORM)
- Rate limiting on mutations
Integration with Other Skills
With Next.js
- Server Components can call procedures directly
- App Router API routes for tRPC handler
- Streaming with React Query suspense
With Prisma
- Share context between tRPC and Prisma
- Use Prisma types for input/output validation
- Transaction support in mutations
With Zod
- Input validation for all procedures
- Type inference from schemas
- Reusable validation schemas
With NextAuth
- Session in tRPC context
- Protected procedures via middleware
- Role-based access control
With WebSocket (Subscriptions)
- Real-time data updates
- Observable pattern for streams
- Event-driven architecture
With React Query
- Automatic caching and deduplication
- Optimistic updates
- Infinite queries for pagination
References
Official Docs
- tRPC Documentation
- tRPC with Next.js
- React Query Integration
- Subscriptions Guide
- Procedure Builder Options
Examples
Community
Meta: Skill Quality
Completeness: ✅ Comprehensive (9/9 sections)
Karpathy Principles: ✅ 8 mentions
Code Examples: ✅ 23+ working examples
Production Tested: ✅ Used in MemoryRelay API layer
Last Updated: 2026-04-14
Coverage:
- ✅ Complete setup (Next.js App Router)
- ✅ Queries, mutations, subscriptions (WebSocket)
- ✅ Middleware and protected procedures
- ✅ Input validation with Zod
- ✅ React Query integration
- ✅ Server-side calls (RSC)
- ✅ Optimistic updates
- ✅ Infinite queries
- ✅ Procedure composition (inferProcedureBuilderResolverOptions)
- ✅ Error handling
- ✅ Type safety patterns
- ✅ Batching and caching
Skill Level: Intermediate
Time to Learn: 3-4 hours
Prerequisites Met: TypeScript, React Query, Next.js
Known Gaps: None - production-ready
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

