tRPC Best Practices

Expert guidance for tRPC (TypeScript Remote Procedure Call) including router setup, procedures, middleware, context, client configuration, and Next.js integration. Use this when building type-safe APIs, integrating tRPC with Next.js, or implementing client-server communication with full TypeScript inference.

MIT-0 · Free to use, modify, and redistribute. No attribution required.
1 · 1.4k · 2 current installs · 2 all-time installs
MIT-0
Security Scan
VirusTotalVirusTotal
Benign
View report →
OpenClawOpenClaw
Benign
high confidence
Purpose & Capability
The name/description (tRPC best practices) matches the SKILL.md content: TypeScript/Next.js/tRPC code examples, router/client setup, middleware, and validation. The skill does not request unrelated binaries, env vars, or credentials. Note: the skill's source/homepage are missing, so you should treat the origin as unknown even though the content itself is coherent.
Instruction Scope
SKILL.md contains code examples and step-by-step guidance confined to building/integrating tRPC (client/server, adapters, Zod validation, Next.js). It references placeholders like db.user and a localhost API URL for examples, which are typical examples rather than instructions to access external secrets. The instructions do not direct the agent to read unrelated system files, config paths, or to exfiltrate data.
Install Mechanism
There is no install spec and no code files; the skill is instruction-only. The Quick Start recommends running npm install for standard @trpc and peer packages, which is expected for this topic. No downloads from arbitrary URLs or archive extraction are present in the skill itself.
Credentials
The skill declares no required environment variables, credentials, or config paths. Example code references (db, localhost URL) are typical placeholders and do not translate to required secrets being requested by the skill.
Persistence & Privilege
always is false and there is no install or persistent presence requested. The skill is user-invocable and may be invoked autonomously by the agent (default), which is normal for skills and not a concern here given the lack of broad privileges or credential requests.
Assessment
This instruction-only guide appears internally consistent and low-risk, but consider these practical checks before using or copy-pasting examples: 1) Verify the origin/version — the package owner/homepage is missing, so ensure recommended package versions (e.g., @trpc/*@next) are what you intend and come from npm. 2) Audit and pin dependency versions before running npm install to avoid pulling unexpected code. 3) Treat db.* calls and localhost URLs as placeholders — do not paste production credentials or connection strings into examples. 4) If you intend to follow the Quick Start, run commands locally in a controlled environment and review changes before committing. 5) If you need provenance, prefer similar guidance from an official tRPC docs source or a known author.

Like a lobster shell, security has layers — review code before you run it.

Current versionv1.0.0
Download zip
latestvk97fggf6k3n9adzx9v0btk0mrn80k6z9

License

MIT-0
Free to use, modify, and redistribute. No attribution required.

SKILL.md

tRPC

Expert assistance with tRPC - End-to-end typesafe APIs with TypeScript.

Overview

tRPC enables building fully typesafe APIs without schemas or code generation:

  • Full TypeScript inference from server to client
  • No code generation needed
  • Excellent DX with autocomplete and type safety
  • Works great with Next.js, React Query, and more

Quick Start

Installation

# Core packages
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next

# Peer dependencies
npm install @tanstack/react-query@latest zod

Basic Setup (Next.js App Router)

1. Create tRPC Router

// server/trpc.ts
import { initTRPC } from '@trpc/server'
import { z } from 'zod'

const t = initTRPC.create()

export const router = t.router
export const publicProcedure = t.procedure

2. Define API Router

// 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}!` }
    }),

  createUser: publicProcedure
    .input(z.object({
      name: z.string(),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      const user = await db.user.create({ data: input })
      return user
    }),
})

export type AppRouter = typeof appRouter

3. Create API Route

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}),
  })

export { handler as GET, handler as POST }

4. Setup Client Provider

// 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'

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    })
  )

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  )
}

5. Create tRPC Client

// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'

export const trpc = createTRPCReact<AppRouter>()

6. Use in Components

'use client'

import { trpc } from '@/lib/trpc'

export default function Home() {
  const hello = trpc.hello.useQuery({ name: 'World' })
  const createUser = trpc.createUser.useMutation()

  return (
    <div>
      <p>{hello.data?.greeting}</p>
      <button
        onClick={() => createUser.mutate({
          name: 'John',
          email: 'john@example.com'
        })}
      >
        Create User
      </button>
    </div>
  )
}

Router Definition

Basic Router

import { router, publicProcedure } from './trpc'
import { z } from 'zod'

export const userRouter = router({
  // Query - for fetching data
  getById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input } })
    }),

  // Mutation - for creating/updating/deleting
  create: publicProcedure
    .input(z.object({
      name: z.string(),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input })
    }),

  // Subscription - for real-time updates
  onUpdate: publicProcedure
    .subscription(() => {
      return observable<User>((emit) => {
        // Implementation
      })
    }),
})

Nested Routers

import { router } from './trpc'
import { userRouter } from './routers/user'
import { postRouter } from './routers/post'
import { commentRouter } from './routers/comment'

export const appRouter = router({
  user: userRouter,
  post: postRouter,
  comment: commentRouter,
})

// Usage on client:
// trpc.user.getById.useQuery('123')
// trpc.post.list.useQuery()
// trpc.comment.create.useMutation()

Merging Routers

import { router, publicProcedure } from './trpc'

const userRouter = router({
  list: publicProcedure.query(() => {/* ... */}),
  getById: publicProcedure.input(z.string()).query(() => {/* ... */}),
})

const postRouter = router({
  list: publicProcedure.query(() => {/* ... */}),
  create: publicProcedure.input(z.object({})).mutation(() => {/* ... */}),
})

// Merge into app router
export const appRouter = router({
  user: userRouter,
  post: postRouter,
})

Input Validation with Zod

Basic Validation

import { z } from 'zod'

export const userRouter = router({
  create: publicProcedure
    .input(z.object({
      name: z.string().min(2).max(50),
      email: z.string().email(),
      age: z.number().int().positive().optional(),
      role: z.enum(['user', 'admin']),
    }))
    .mutation(async ({ input }) => {
      // input is fully typed!
      return await db.user.create({ data: input })
    }),
})

Complex Validation

const createPostInput = z.object({
  title: z.string().min(5).max(100),
  content: z.string().min(10),
  published: z.boolean().default(false),
  tags: z.array(z.string()).min(1).max(5),
  metadata: z.object({
    views: z.number().default(0),
    likes: z.number().default(0),
  }).optional(),
})

export const postRouter = router({
  create: publicProcedure
    .input(createPostInput)
    .mutation(async ({ input }) => {
      return await db.post.create({ data: input })
    }),
})

Reusable Schemas

// schemas/user.ts
export const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
})

export const createUserSchema = userSchema.omit({ id: true })
export const updateUserSchema = userSchema.partial()

// Use in router
export const userRouter = router({
  create: publicProcedure
    .input(createUserSchema)
    .mutation(({ input }) => {/* ... */}),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      data: updateUserSchema,
    }))
    .mutation(({ input }) => {/* ... */}),
})

Context

Creating Context

// server/context.ts
import { inferAsyncReturnType } from '@trpc/server'
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'

export async function createContext(opts: FetchCreateContextFnOptions) {
  // Get session from cookies/headers
  const session = await getSession(opts.req)

  return {
    session,
    db,
  }
}

export type Context = inferAsyncReturnType<typeof createContext>

Using Context in tRPC

// server/trpc.ts
import { initTRPC } from '@trpc/server'
import { Context } from './context'

const t = initTRPC.context<Context>().create()

export const router = t.router
export const publicProcedure = t.procedure

Accessing Context in Procedures

export const userRouter = router({
  me: publicProcedure.query(({ ctx }) => {
    // ctx.session, ctx.db are available
    if (!ctx.session) {
      throw new TRPCError({ code: 'UNAUTHORIZED' })
    }

    return ctx.db.user.findUnique({
      where: { id: ctx.session.userId }
    })
  }),
})

Middleware

Creating Middleware

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'

const t = initTRPC.context<Context>().create()

// Logging middleware
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} took ${duration}ms`)

  return result
})

// Auth middleware
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }

  return next({
    ctx: {
      // Infers session is non-nullable
      session: ctx.session,
    },
  })
})

// Create procedures with middleware
export const publicProcedure = t.procedure.use(loggerMiddleware)
export const protectedProcedure = t.procedure.use(loggerMiddleware).use(isAuthed)

Using Protected Procedures

export const postRouter = router({
  // Public - anyone can access
  list: publicProcedure.query(() => {
    return db.post.findMany({ where: { published: true } })
  }),

  // Protected - requires authentication
  create: protectedProcedure
    .input(z.object({ title: z.string() }))
    .mutation(({ ctx, input }) => {
      // ctx.session is guaranteed to exist
      return db.post.create({
        data: {
          ...input,
          authorId: ctx.session.userId,
        },
      })
    }),
})

Role-Based Middleware

const requireRole = (role: string) =>
  t.middleware(({ ctx, next }) => {
    if (!ctx.session || ctx.session.role !== role) {
      throw new TRPCError({ code: 'FORBIDDEN' })
    }
    return next()
  })

export const adminProcedure = protectedProcedure.use(requireRole('admin'))

export const userRouter = router({
  delete: adminProcedure
    .input(z.string())
    .mutation(({ input }) => {
      return db.user.delete({ where: { id: input } })
    }),
})

Client Usage

Queries

'use client'

import { trpc } from '@/lib/trpc'

export default function UserList() {
  // Basic query
  const users = trpc.user.list.useQuery()

  // Query with input
  const user = trpc.user.getById.useQuery('user-123')

  // Disabled query
  const profile = trpc.user.getProfile.useQuery(
    { id: userId },
    { enabled: !!userId }
  )

  // With options
  const posts = trpc.post.list.useQuery(undefined, {
    refetchInterval: 5000,
    staleTime: 1000,
  })

  if (users.isLoading) return <div>Loading...</div>
  if (users.error) return <div>Error: {users.error.message}</div>

  return (
    <ul>
      {users.data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

Mutations

'use client'

export default function CreateUser() {
  const utils = trpc.useContext()

  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Invalidate and refetch
      utils.user.list.invalidate()
    },
    onError: (error) => {
      console.error('Failed to create user:', error)
    },
  })

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    createUser.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit" disabled={createUser.isLoading}>
        {createUser.isLoading ? 'Creating...' : 'Create User'}
      </button>
    </form>
  )
}

Optimistic Updates

const updatePost = trpc.post.update.useMutation({
  onMutate: async (newPost) => {
    // Cancel outgoing refetches
    await utils.post.list.cancel()

    // Snapshot previous value
    const previousPosts = utils.post.list.getData()

    // Optimistically update
    utils.post.list.setData(undefined, (old) =>
      old?.map(post =>
        post.id === newPost.id ? { ...post, ...newPost } : post
      )
    )

    return { previousPosts }
  },
  onError: (err, newPost, context) => {
    // Rollback on error
    utils.post.list.setData(undefined, context?.previousPosts)
  },
  onSettled: () => {
    // Refetch after success or error
    utils.post.list.invalidate()
  },
})

Infinite Queries

// Server
export const postRouter = router({
  list: publicProcedure
    .input(z.object({
      cursor: z.string().optional(),
      limit: z.number().min(1).max(100).default(10),
    }))
    .query(async ({ input }) => {
      const posts = await db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
      })

      let nextCursor: string | undefined = undefined
      if (posts.length > input.limit) {
        const nextItem = posts.pop()
        nextCursor = nextItem!.id
      }

      return { posts, nextCursor }
    }),
})

// Client
export default function InfinitePosts() {
  const posts = trpc.post.list.useInfiniteQuery(
    { limit: 10 },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    }
  )

  return (
    <div>
      {posts.data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}

      <button
        onClick={() => posts.fetchNextPage()}
        disabled={!posts.hasNextPage || posts.isFetchingNextPage}
      >
        {posts.isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </div>
  )
}

Error Handling

Server Errors

import { TRPCError } from '@trpc/server'

export const postRouter = router({
  getById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      const post = await db.post.findUnique({ where: { id: input } })

      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Post not found',
        })
      }

      return post
    }),

  create: protectedProcedure
    .input(z.object({ title: z.string() }))
    .mutation(async ({ ctx, input }) => {
      if (!ctx.session.verified) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Email must be verified',
        })
      }

      try {
        return await db.post.create({ data: input })
      } catch (error) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Failed to create post',
          cause: error,
        })
      }
    }),
})

Error Codes

  • BAD_REQUEST - Invalid input
  • UNAUTHORIZED - Not authenticated
  • FORBIDDEN - Not authorized
  • NOT_FOUND - Resource not found
  • TIMEOUT - Request timeout
  • CONFLICT - Resource conflict
  • PRECONDITION_FAILED - Precondition check failed
  • PAYLOAD_TOO_LARGE - Request too large
  • METHOD_NOT_SUPPORTED - HTTP method not supported
  • TOO_MANY_REQUESTS - Rate limited
  • CLIENT_CLOSED_REQUEST - Client closed request
  • INTERNAL_SERVER_ERROR - Server error

Client Error Handling

const createPost = trpc.post.create.useMutation({
  onError: (error) => {
    if (error.data?.code === 'UNAUTHORIZED') {
      router.push('/login')
    } else if (error.data?.code === 'FORBIDDEN') {
      alert('You do not have permission')
    } else {
      alert('Something went wrong')
    }
  },
})

Server-Side Calls

In Server Components

// app/users/page.tsx
import { createCaller } from '@/server/routers/_app'
import { createContext } from '@/server/context'

export default async function UsersPage() {
  const ctx = await createContext({ req: {} as any })
  const caller = createCaller(ctx)

  const users = await caller.user.list()

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

Create Caller

// server/routers/_app.ts
export const createCaller = createCallerFactory(appRouter)

// Usage
const caller = createCaller(ctx)
const user = await caller.user.getById('123')

Advanced Patterns

Request Batching

import { httpBatchLink } from '@trpc/client'

const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: '/api/trpc',
      maxURLLength: 2083, // Reasonable limit
    }),
  ],
})

Request Deduplication

Automatic with React Query - multiple components requesting same data will only make one request.

Custom Headers

const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: '/api/trpc',
      headers: () => {
        return {
          Authorization: `Bearer ${getToken()}`,
        }
      },
    }),
  ],
})

Error Formatting

// server/trpc.ts
const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    }
  },
})

Testing

Testing Procedures

import { appRouter } from '@/server/routers/_app'
import { createCaller } from '@/server/routers/_app'

describe('user router', () => {
  it('creates user', async () => {
    const ctx = { session: mockSession, db: mockDb }
    const caller = createCaller(ctx)

    const user = await caller.user.create({
      name: 'John',
      email: 'john@example.com',
    })

    expect(user.name).toBe('John')
  })
})

Best Practices

  1. Use Zod for validation - Always validate inputs
  2. Keep procedures small - Single responsibility
  3. Use middleware for auth - Don't repeat auth checks
  4. Type your context - Full type safety
  5. Organize routers - Split into logical domains
  6. Handle errors properly - Use appropriate error codes
  7. Leverage React Query - Use its caching and refetching
  8. Batch requests - Enable batching for better performance
  9. Use optimistic updates - Better UX
  10. Document procedures - Add JSDoc comments

Resources

Files

1 total
Select a file
Select a file to preview.

Comments

Loading comments…