tRPC Core Knowledge

SkillAI & models

tRPC for type-safe APIs. Covers routers, procedures, and React Query integration. Use for end-to-end type-safe APIs.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the tRPC Core Knowledge skill

What this skill tells your AI

The instructions your AI receives, as published by claude-dev-suite/claude-dev-suite in skills/api-design/trpc/SKILL.md and read by ahel’s review.

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: trpc for comprehensive documentation.

Router Definition

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

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

export const appRouter = t.router({
  user: t.router({
    list: t.procedure.query(async ({ ctx }) => {
      return ctx.db.users.findMany();
    }),

    byId: t.procedure
      .input(z.string())
      .query(async ({ ctx, input }) => {
        const user = await ctx.db.users.find(input);
        if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
        return user;
      }),

    create: t.procedure
      .input(z.object({
        name: z.string().min(1),
        email: z.string().email(),
      }))
      .mutation(async ({ ctx, input }) => {
        return ctx.db.users.create(input);
      }),
  }),
});

export type AppRouter = typeof appRouter;

Client Usage (React)

import { trpc } from '../utils/trpc';

function UserList() {
  const { data, isLoading } = trpc.user.list.useQuery();
  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      utils.user.list.invalidate();
    },
  });

  if (isLoading) return <Spinner />;

  return (
    <div>
      {data?.map(user => <UserCard key={user.id} user={user} />)}
      <button onClick={() => createUser.mutate({ name: 'John', email: 'j@example.com' })}>
        Add User
      </button>
    </div>
  );
}

Protected Procedures

const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { user: ctx.session.user } });
});

export const appRouter = t.router({
  secret: protectedProcedure.query(({ ctx }) => {
    return `Hello ${ctx.user.name}`;
  }),
});

With Next.js

// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';

export default createNextApiHandler({
  router: appRouter,
  createContext: ({ req, res }) => ({ req, res, db }),
});

When NOT to Use This Skill

  • REST API design (use rest-api skill)
  • GraphQL APIs (use graphql skill)
  • OpenAPI documentation (use openapi skill)
  • Non-TypeScript projects
  • Public APIs requiring language-agnostic clients
  • APIs consumed by third-party developers

Anti-Patterns

Anti-PatternWhy It's BadSolution
No input validationSecurity risk, runtime errorsAlways use Zod schemas for input
Sharing database models as output typesLeaks implementation detailsCreate separate DTOs/response schemas
Missing error handling middlewareInconsistent error responsesAdd global error middleware
No rate limiting on public proceduresAPI abuse vulnerabilityAdd rate limiting middleware
Using any in context or proceduresLoses type safetyUse proper TypeScript types
Not using middleware for authDuplicated auth logicCreate reusable protected procedure
Missing pagination on list queriesPerformance issuesAdd pagination to all list endpoints
Exposing internal errors to clientSecurity leakUse error formatter to sanitize errors
No request loggingHard to debug issuesAdd logging middleware

Quick Troubleshooting

IssuePossible CauseSolution
Type errors in clientRouter type not exported/importedExport AppRouter type from server
"UNAUTHORIZED" errorsMissing or invalid contextCheck createContext, verify token
Input validation failsInput doesn't match Zod schemaVerify request payload matches schema
Slow queriesMissing DataLoader or N+1 queriesAdd batching/caching in context
CORS errorsMissing CORS configurationConfigure CORS in adapter
Procedure not foundRouter not registered or typoCheck router structure, verify path
"Cannot call procedure"Calling query as mutation or vice versaUse correct method (query/mutation)
Rate limit errorsToo many requestsImplement exponential backoff
Type inference not workingIncorrect client setupEnsure client uses correct AppRouter type

Production Readiness

Error Handling

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

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // Add custom error data
        zodError:
          error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

// Custom error handling middleware
const errorMiddleware = t.middleware(async ({ next }) => {
  try {
    return await next();
  } catch (error) {
    if (error instanceof TRPCError) {
      // Log known errors
      console.warn('tRPC error:', error.code, error.message);
      throw error;
    }

    // Log and transform unknown errors
    console.error('Unexpected error:', error);
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: 'An unexpected error occurred',
    });
  }
});

export const procedure = t.procedure.use(errorMiddleware);

Rate Limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'),
});

const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const identifier = ctx.session?.user?.id ?? ctx.ip ?? 'anonymous';
  const { success, limit, reset, remaining } = await ratelimit.limit(identifier);

  if (!success) {
    throw new TRPCError({
      code: 'TOO_MANY_REQUESTS',
      message: `Rate limit exceeded. Try again in ${Math.ceil((reset - Date.now()) / 1000)}s`,
    });
  }

  return next();
});

export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);

Input Validation

import { z } from 'zod';

// Reusable schemas
const paginationSchema = z.object({
  page: z.number().min(1).default(1),
  limit: z.number().min(1).max(100).default(20),
});

const idSchema = z.string().uuid();

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user'),
});

export const appRouter = t.router({
  user: t.router({
    list: procedure
      .input(paginationSchema)
      .query(async ({ ctx, input }) => {
        const { page, limit } = input;
        return ctx.db.users.findMany({
          skip: (page - 1) * limit,
          take: limit,
        });
      }),

    create: protectedProcedure
      .input(createUserSchema)
      .mutation(async ({ ctx, input }) => {
        return ctx.db.users.create({ data: input });
      }),
  }),
});

Testing

// server/routers/__tests__/user.test.ts
import { createCallerFactory } from '@trpc/server';
import { appRouter } from '../_app';
import { createMockContext } from '../../test/context';

const createCaller = createCallerFactory(appRouter);

describe('user router', () => {
  it('lists users', async () => {
    const ctx = createMockContext({
      db: {
        users: {
          findMany: vi.fn().mockResolvedValue([{ id: '1', name: 'Test' }]),
        },
      },
    });

    const caller = createCaller(ctx);
    const result = await caller.user.list({ page: 1, limit: 10 });

    expect(result).toHaveLength(1);
    expect(ctx.db.users.findMany).toHaveBeenCalled();
  });

  it('throws on unauthorized access', async () => {
    const ctx = createMockContext({ session: null });
    const caller = createCaller(ctx);

    await expect(caller.user.create({ name: 'Test', email: 'test@example.com' }))
      .rejects.toThrow('UNAUTHORIZED');
  });
});

// E2E testing
import { test, expect } from '@playwright/test';

test('creates user via tRPC', async ({ page }) => {
  await page.goto('/users');
  await page.fill('input[name="name"]', 'John');
  await page.fill('input[name="email"]', 'john@example.com');
  await page.click('button[type="submit"]');

  await expect(page.locator('text=John')).toBeVisible();
});

Monitoring Metrics

MetricTarget
Procedure latency< 200ms
Error rate< 1%
Rate limit hitsMonitor
Query cache hit rate> 80%

Checklist

  • Error formatter configured
  • Error logging middleware
  • Rate limiting on mutations
  • Input validation with Zod
  • Protected procedures for auth
  • Pagination on list queries
  • Unit tests with createCaller
  • E2E tests for critical flows
  • Request logging
  • Query invalidation strategy

Reference Documentation

Signals

GitHub stars
33
Forks
8
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
trpc
Source
github.com/claude-dev-suite/claude-dev-suite