agentleFS
AlteriomAlteriom/ai-dev-skills/skills/trpc-best-practices/SKILL.md

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

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
  1. Distinctive:

    Use this skill when:

    line 26
  2. Distinctive:

    Don't use: when you need:

    line 36
  3. Distinctive:

    Karpathy Principle: Simplicity First: - Start with a single router file.

    line 86
  4. Very specific:

    Karpathy Principle: Goal-Driven Execution: - After setup, verify type inference works: hover over hello.data - you should see { greeting: …

    line 233
  5. Very specific:

    Karpathy Principle: Surgical Changes: - Add auth middleware once, reuse everywhere.

    line 386
  6. Distinctive:

    Before deploying tRPC APIs:

    line 737
  7. Distinctive:

    Use Prisma types for input/output validation

    line 772

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 serialization

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

Karpathy 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.procedure

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

Step 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 here

3. 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 procedures

7. 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 any types in procedure handlers
  • Context type exported and used correctly

Performance

  • Using httpBatchLink for batching
  • Query invalidation working (UI updates after mutations)
  • Caching configured (staleTime, cacheTime)
  • Transformer configured if using Dates/Maps/Sets

Error Handling

  • Auth errors throw UNAUTHORIZED code
  • Not found errors throw NOT_FOUND code
  • 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

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

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.