Claude Code for tRPC: End-to-End Type Safety Without Code Generation — Claude Skills 360 Blog
Blog / Development / Claude Code for tRPC: End-to-End Type Safety Without Code Generation
Development

Claude Code for tRPC: End-to-End Type Safety Without Code Generation

Published: June 12, 2026
Read time: 8 min read
By: Claude Skills 360

tRPC eliminates the contract duplication between frontend and backend that makes REST APIs painful in TypeScript projects. Define a procedure on the server, and the client gets full type safety automatically — no OpenAPI schemas, no codegen, no matching types by hand. Claude Code generates tRPC routers and clients in the modern pattern that integrates cleanly with Next.js, React Query, and Zod.

This guide covers tRPC with Claude Code: routers, middleware, input validation, React Query integration, and subscriptions.

tRPC Setup

Set up tRPC v11 in a Next.js App Router project.
I need: Zod input validation, auth middleware, and React Query integration.
// src/server/api/trpc.ts — core setup
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { authOptions } from '../auth';
import { db } from '../db';

// Context available in all procedures
export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await getServerSession(authOptions);
  return { db, session, headers: opts.headers };
};

type Context = Awaited<ReturnType<typeof createTRPCContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson,          // Handles Date, Map, Set, Uint8Array natively
  errorFormatter: ({ shape, error }) => ({
    ...shape,
    data: {
      ...shape.data,
      zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
    },
  }),
});

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

// Middleware that throws if not authenticated
const enforceAuth = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      ...ctx,
      session: ctx.session,       // Now typed as non-null
      user: ctx.session.user,     // Convenience
    },
  });
});

export const protectedProcedure = t.procedure.use(enforceAuth);

// Admin middleware
const enforceAdmin = enforceAuth.unstable_pipe(({ ctx, next }) => {
  if (ctx.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

export const adminProcedure = t.procedure.use(enforceAdmin);

CLAUDE.md for tRPC Projects

## tRPC Conventions
- Router files in src/server/api/routers/{resource}.ts
- Root router in src/server/api/root.ts
- Client in src/trpc/client.ts, server in src/trpc/server.ts
- All mutations use protectedProcedure (require auth)
- Input validation with Zod — always validate at the procedure level
- Error codes: UNAUTHORIZED (not logged in), FORBIDDEN (logged in, no permission),
  NOT_FOUND, BAD_REQUEST for invalid input, INTERNAL_SERVER_ERROR for unexpected failures
- React Query keys are managed by tRPC — don't create manual query keys

Building Routers

Create a posts router with CRUD operations.
Posts belong to authenticated users. Pagination for listing.
// src/server/api/routers/posts.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

const createPostInput = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(50000),
  tags: z.array(z.string()).max(10).default([]),
  published: z.boolean().default(false),
});

export const postsRouter = createTRPCRouter({
  // Public — no auth required
  list: publicProcedure
    .input(z.object({
      cursor: z.string().optional(),
      limit: z.number().min(1).max(50).default(20),
      tag: z.string().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const { cursor, limit, tag } = input;

      const posts = await ctx.db.post.findMany({
        take: limit + 1, // Get one extra to determine hasMore
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: 'desc' },
        where: {
          published: true,
          ...(tag && { tags: { some: { name: tag } } }),
        },
        include: { author: { select: { id: true, name: true, image: true } } },
      });

      const hasMore = posts.length > limit;
      const items = hasMore ? posts.slice(0, -1) : posts;
      const nextCursor = hasMore ? items[items.length - 1]?.id : undefined;

      return { items, nextCursor };
    }),

  // By ID — public
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
        include: { author: { select: { id: true, name: true } } },
      });
      if (!post || (!post.published && post.authorId !== ctx.session?.user?.id)) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      return post;
    }),

  // Create — requires auth
  create: protectedProcedure
    .input(createPostInput)
    .mutation(async ({ ctx, input }) => {
      return ctx.db.post.create({
        data: { ...input, authorId: ctx.user.id },
      });
    }),

  // Update — requires auth + ownership
  update: protectedProcedure
    .input(z.object({
      id: z.string(),
      data: createPostInput.partial(),
    }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({ where: { id: input.id } });
      if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
      if (post.authorId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });

      return ctx.db.post.update({
        where: { id: input.id },
        data: input.data,
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({ where: { id: input.id } });
      if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
      if (post.authorId !== ctx.user.id && ctx.user.role !== 'admin') {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      await ctx.db.post.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

React Query Integration

Use the posts router in a React component with:
infinite scrolling for the list, optimistic updates for likes,
and prefetching on hover.
// src/app/blog/page.tsx (server component — prefetch)
import { createServerCaller } from '@/trpc/server';

export default async function BlogPage() {
  const trpc = await createServerCaller();
  // Prefetch first page — hydrates client cache
  await trpc.posts.list.prefetch({ limit: 20 });

  return <BlogList />;
}

// src/app/blog/BlogList.tsx (client component)
'use client';
import { api } from '@/trpc/client';

export function BlogList() {
  // Infinite scroll — tRPC + React Query integration
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    api.posts.list.useInfiniteQuery(
      { limit: 20 },
      { getNextPageParam: (lastPage) => lastPage.nextCursor },
    );

  const allPosts = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      {allPosts.map(post => <PostCard key={post.id} post={post} />)}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load more'}
        </button>
      )}
    </div>
  );
}
// Optimistic updates for likes
function LikeButton({ postId, likeCount }: { postId: string; likeCount: number }) {
  const utils = api.useUtils();
  const likeMutation = api.posts.like.useMutation({
    onMutate: async ({ postId }) => {
      // Cancel any outgoing refetches
      await utils.posts.byId.cancel({ id: postId });
      
      // Snapshot previous value
      const prev = utils.posts.byId.getData({ id: postId });
      
      // Optimistically update
      utils.posts.byId.setData({ id: postId }, (old) =>
        old ? { ...old, likeCount: old.likeCount + 1 } : old
      );
      
      return { prev };
    },
    onError: (err, { postId }, context) => {
      // Rollback on error
      utils.posts.byId.setData({ id: postId }, context?.prev);
    },
    onSettled: ({ postId }) => {
      // Refetch to sync server state
      utils.posts.byId.invalidate({ id: postId });
    },
  });

  return (
    <button onClick={() => likeMutation.mutate({ postId })}>
      ♥ {likeCount}
    </button>
  );
}

Error Handling

Handle tRPC errors on the client — show specific errors for
validation failures, auth errors, and unexpected server errors.
function useCreatePost() {
  const utils = api.useUtils();
  return api.posts.create.useMutation({
    onSuccess: () => {
      utils.posts.list.invalidate();
      router.push('/blog');
    },
    onError: (error) => {
      if (error.data?.zodError) {
        // Validation error — show field-level errors
        const fieldErrors = error.data.zodError.fieldErrors;
        Object.entries(fieldErrors).forEach(([field, errors]) => {
          form.setError(field as any, { message: errors?.[0] });
        });
      } else if (error.data?.code === 'UNAUTHORIZED') {
        router.push('/login');
      } else {
        toast.error('Failed to create post. Please try again.');
      }
    },
  });
}

For the Next.js App Router patterns that host tRPC including server components and form actions, see the Next.js App Router guide. For the database layer, see the Prisma guide. The Claude Skills 360 bundle includes tRPC skill sets for router design patterns, middleware, and React Query integration. Start with the free tier to try tRPC router generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free