home / skills / agents-inc / skills / web-data-fetching-trpc

web-data-fetching-trpc skill

/src/skills/web-data-fetching-trpc

This skill enables end-to-end type-safe tRPC APIs in TypeScript monorepos, sharing router types and enforcing validation from server to client.

npx playbooks add skill agents-inc/skills --skill web-data-fetching-trpc

Review the files below or copy the command above to add this skill to your agents.

Files (9)
SKILL.md
20.0 KB
---
name: web-data-fetching-trpc
description: tRPC type-safe API patterns, procedures, React Query integration
---

# tRPC Type-Safe API Patterns

> **Quick Guide:** Use tRPC for end-to-end type-safe APIs in TypeScript monorepos. Eliminates schema duplication and code generation - types flow automatically from backend to frontend.

---

<critical_requirements>

## CRITICAL: Before Using This Skill

**(You MUST export `AppRouter` type from your tRPC router for client-side type inference)**

**(You MUST wrap async state mutations in `runInAction()` when using tRPC with reactive stores)**

**(You MUST use `TRPCError` with appropriate error codes - never throw raw Error objects)**

**(You MUST use Zod for input validation on ALL procedures accepting user input)**

**(You MUST use named constants for ALL timeout/retry values - NO magic numbers)**

</critical_requirements>

---

**Auto-detection:** tRPC router, initTRPC, createTRPCClient, @trpc/server, @trpc/client, @trpc/react-query, TRPCError, procedure, query, mutation, httpBatchLink

**When to use:**

- Building APIs in TypeScript monorepos with shared types
- Need end-to-end type safety without code generation
- Full-stack TypeScript applications (Next.js, T3 Stack)
- Projects where both client and server are TypeScript

**When NOT to use:**

- Public APIs consumed by third parties (use OpenAPI/REST)
- Non-TypeScript clients (mobile apps, other languages)
- GraphQL requirements (use Apollo/urql)
- Need HTTP caching at CDN level (tRPC uses POST by default)

**Key patterns covered:**

- Router and procedure definition (initTRPC, router, procedure)
- Input validation with Zod schemas
- Context and middleware for authentication
- Error handling with TRPCError codes
- React Query integration (@trpc/react-query)
- Optimistic updates and cache invalidation

**Detailed Resources:**

- For code examples, see [examples/](examples/)
- For decision frameworks and anti-patterns, see [reference.md](reference.md)

---

<philosophy>

## Philosophy

tRPC eliminates the API layer friction by sharing types directly between server and client. No schemas to write, no code to generate - just export your router type and import it client-side for full autocompletion and type safety.

**Core principles:**

- **Zero schema duplication**: Types flow from backend to frontend automatically
- **TypeScript-native**: Leverages TypeScript's type inference, not code generation
- **Procedure-based**: Queries read data, mutations write data - clear separation
- **Composable middleware**: Build reusable authentication and validation layers
- **React Query integration**: Full caching, invalidation, and optimistic updates

**Trade-offs:**

- Requires TypeScript on both ends (no polyglot support)
- Best in monorepos where types can be shared directly
- Not suitable for public APIs needing OpenAPI documentation

</philosophy>

---

<patterns>

## Core Patterns

### Pattern 1: tRPC Initialization and Router Setup

Initialize tRPC once per application. Export the router and procedure factories for use across your codebase.

#### Constants

```typescript
// packages/api/src/trpc/index.ts
const TRPC_VERSION = "11";
```

#### Implementation

```typescript
// packages/api/src/trpc/index.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import type { Context } from "./context";

// Initialize tRPC with context type
const t = initTRPC.context<Context>().create({
  // Format Zod errors for better client experience
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

// Export reusable router and procedure factories
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;

// Named exports (project convention)
export { router, publicProcedure, middleware };
```

**Why good:** Single initialization point ensures consistency, error formatter provides structured Zod errors to client, exported factories enable composition across router files

---

### Pattern 2: Defining Procedures with Zod Input Validation

Use Zod schemas for runtime input validation. tRPC automatically infers types from Zod schemas.

#### Implementation

```typescript
// packages/api/src/routers/user.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";

// Define input schemas as constants
const userIdSchema = z.object({
  id: z.string().uuid(),
});

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
});

export const userRouter = router({
  // Query - read operations
  getById: publicProcedure.input(userIdSchema).query(async ({ input, ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: input.id },
    });

    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: `User with id ${input.id} not found`,
      });
    }

    return user;
  }),

  // Mutation - write operations
  create: publicProcedure
    .input(createUserSchema)
    .mutation(async ({ input, ctx }) => {
      return ctx.db.user.create({
        data: input,
      });
    }),
});

// Named export
export { userRouter };
```

**Why good:** Zod schemas provide runtime validation and TypeScript inference, error codes map to HTTP status codes, input/output types flow to client automatically

---

### Pattern 3: Context and Authentication Middleware

Create protected procedures using middleware. Context flows through all procedures.

#### Constants

```typescript
const SESSION_COOKIE_NAME = "session_token";
```

#### Context Creation

```typescript
// packages/api/src/trpc/context.ts
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { Session, User } from "@repo/db";

export interface Context {
  session: Session | null;
  user: User | null;
  db: typeof db;
}

export async function createContext(
  opts: CreateNextContextOptions,
): Promise<Context> {
  const session = await getSessionFromRequest(opts.req);
  const user = session ? await getUserFromSession(session) : null;

  return {
    session,
    user,
    db,
  };
}

// Named export
export { createContext };
export type { Context };
```

#### Protected Procedure Middleware

```typescript
// packages/api/src/trpc/middleware.ts
import { TRPCError } from "@trpc/server";
import { middleware, publicProcedure } from "./index";

// Authentication middleware
const isAuthenticated = middleware(async ({ ctx, next }) => {
  if (!ctx.session || !ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to perform this action",
    });
  }

  // Return narrowed context - user is now non-nullable
  return next({
    ctx: {
      ...ctx,
      session: ctx.session,
      user: ctx.user,
    },
  });
});

// Admin-only middleware
const isAdmin = middleware(async ({ ctx, next }) => {
  if (!ctx.user?.isAdmin) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }

  return next({ ctx });
});

// Create protected procedure by chaining middleware
export const protectedProcedure = publicProcedure.use(isAuthenticated);
export const adminProcedure = protectedProcedure.use(isAdmin);

// Named exports
export { protectedProcedure, adminProcedure };
```

**Why good:** Middleware narrows TypeScript context types, protected procedures enforce auth at definition time not runtime checks in every handler, composable middleware enables role-based access patterns

---

### Pattern 4: App Router Composition and Type Export

Compose routers and export the type for client-side inference.

#### Implementation

```typescript
// packages/api/src/root.ts
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,
});

// Export type for client-side inference
// This is the KEY to tRPC's type safety
export type AppRouter = typeof appRouter;

// Named exports
export { appRouter };
export type { AppRouter };
```

**Why good:** Router composition enables code organization, AppRouter type export is the bridge for client-side type inference, single export point for all API routes

---

### Pattern 5: React Query Client Setup (tRPC v11)

Configure tRPC with React Query v5 for data fetching, caching, and mutations.

#### Constants

```typescript
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const DEFAULT_RETRY_ATTEMPTS = 3;
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "/api/trpc";
```

#### Implementation (New v11 TanStack Integration - Recommended)

```typescript
// apps/client/lib/trpc.ts
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@repo/api";

// v11: Create typed context providers and hooks
export const { TRPCProvider, useTRPC, useTRPCClient } =
  createTRPCContext<AppRouter>();

// Named exports
export { TRPCProvider, useTRPC, useTRPCClient };
```

```typescript
// apps/client/lib/trpc-provider.tsx
"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { TRPCProvider } from "./trpc";
import type { AppRouter } from "@repo/api";

const FIVE_MINUTES_MS = 5 * 60 * 1000;
const DEFAULT_RETRY_ATTEMPTS = 3;
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "/api/trpc";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: FIVE_MINUTES_MS,
        retry: DEFAULT_RETRY_ATTEMPTS,
      },
      mutations: {
        retry: false, // Don't retry mutations
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined;

function getQueryClient() {
  if (typeof window === "undefined") {
    return makeQueryClient();
  }
  if (!browserQueryClient) browserQueryClient = makeQueryClient();
  return browserQueryClient;
}

export function AppTRPCProvider({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          url: API_URL,
          // v11: transformer goes INSIDE the link (not at client level)
          transformer: superjson,
          // Include credentials for auth cookies
          fetch(url, options) {
            return fetch(url, {
              ...options,
              credentials: "include",
            });
          },
        }),
      ],
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {children}
      </TRPCProvider>
    </QueryClientProvider>
  );
}

// Named export
export { AppTRPCProvider };
```

#### Implementation (Classic Integration - Still Supported)

```typescript
// apps/client/lib/trpc-classic.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@repo/api";

// Classic v10/v11 compatible pattern
export const trpc = createTRPCReact<AppRouter>();

// Named export
export { trpc };
```

**Why good:** httpBatchLink combines multiple requests into single HTTP call, transformer inside link (v11 pattern), QueryClient provides caching layer, credentials include ensures cookies flow for authentication

**v11 CRITICAL:** Transformer must be inside `httpBatchLink()`, NOT at the `createTRPCClient()` level. Placing it at client level will cause errors.

---

### Pattern 6: Using tRPC Hooks in Components

Use generated hooks with full type inference from your backend procedures.

#### Classic Pattern (tRPC v10/v11 compatible)

```typescript
// apps/client/components/user-profile.tsx
import { trpc } from "@/lib/trpc";

export function UserProfile({ userId }: { userId: string }) {
  // Access query utilities for cache manipulation (declare before mutations that use it)
  const utils = trpc.useUtils();

  // Query with full type inference (classic pattern)
  const { data: user, isPending, error } = trpc.user.getById.useQuery({ id: userId });

  // Mutation with automatic type inference
  const updateUser = trpc.user.update.useMutation({
    onSuccess: () => {
      // Invalidate and refetch after mutation
      utils.user.getById.invalidate({ id: userId });
    },
  });

  if (isPending) return <Skeleton />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button
        onClick={() => updateUser.mutate({ id: userId, name: "New Name" })}
        disabled={updateUser.isPending}
      >
        {updateUser.isPending ? "Saving..." : "Update Name"}
      </button>
    </div>
  );
}

// Named export
export { UserProfile };
```

#### New v11 Pattern with queryOptions/mutationOptions (Recommended)

```typescript
// apps/client/components/user-profile-v11.tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/lib/trpc";

export function UserProfileV11({ userId }: { userId: string }) {
  // useTRPC from createTRPCContext provides typed procedure access
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  // v11: Use queryOptions for direct React Query integration
  const { data: user, isPending, error } = useQuery(
    trpc.user.getById.queryOptions({ id: userId })
  );

  // v11: Use mutationOptions for mutations with custom handlers
  const updateUser = useMutation({
    ...trpc.user.update.mutationOptions(),
    onSuccess: () => {
      // Use queryKey for type-safe invalidation
      queryClient.invalidateQueries({
        queryKey: trpc.user.getById.queryKey({ id: userId }),
      });
    },
  });

  if (isPending) return <Skeleton />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button
        onClick={() => updateUser.mutate({ id: userId, name: "New Name" })}
        disabled={updateUser.isPending}
      >
        {updateUser.isPending ? "Saving..." : "Update Name"}
      </button>
    </div>
  );
}

// Named export
export { UserProfileV11 };
```

**Why good:** Classic pattern works in v10/v11, new v11 pattern provides direct React Query access with `queryOptions`/`mutationOptions` factories, `queryKey` enables type-safe cache manipulation, follows TanStack Query patterns directly

---

### Pattern 7: Optimistic Updates

Implement optimistic UI updates for responsive user experience.

#### Implementation

```typescript
// apps/client/components/todo-list.tsx
import { trpc } from "@/lib/trpc";

export function TodoList() {
  const utils = trpc.useUtils();
  const { data: todos } = trpc.todo.list.useQuery();

  const toggleTodo = trpc.todo.toggle.useMutation({
    // Optimistic update before server response
    onMutate: async ({ id }) => {
      // Cancel outgoing refetches
      await utils.todo.list.cancel();

      // Snapshot previous value for rollback
      const previousTodos = utils.todo.list.getData();

      // Optimistically update cache
      utils.todo.list.setData(undefined, (old) =>
        old?.map((todo) =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        )
      );

      // Return context for rollback
      return { previousTodos };
    },

    // Rollback on error
    onError: (err, variables, context) => {
      if (context?.previousTodos) {
        utils.todo.list.setData(undefined, context.previousTodos);
      }
    },

    // Refetch after success or error
    onSettled: () => {
      utils.todo.list.invalidate();
    },
  });

  return (
    <ul>
      {todos?.map((todo) => (
        <li
          key={todo.id}
          onClick={() => toggleTodo.mutate({ id: todo.id })}
          style={{ textDecoration: todo.completed ? "line-through" : "none" }}
        >
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

// Named export
export { TodoList };
```

**Why good:** UI updates immediately without waiting for server, rollback on error preserves data integrity, onSettled ensures eventual consistency with server state

---

### Pattern 8: Error Handling with TRPCError

Use standardized error codes that map to HTTP status codes.

#### Implementation

```typescript
// packages/api/src/routers/post.ts
import { z } from "zod";
import { router, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";

export const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });

      // NOT_FOUND - 404
      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Post not found",
        });
      }

      // FORBIDDEN - 403
      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You can only delete your own posts",
        });
      }

      try {
        await ctx.db.post.delete({ where: { id: input.id } });
        return { success: true };
      } catch (error) {
        // INTERNAL_SERVER_ERROR - 500
        throw new TRPCError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Failed to delete post",
          cause: error, // Preserves original stack trace
        });
      }
    }),
});

// Named export
export { postRouter };
```

#### Client-Side Error Handling

```typescript
// apps/client/components/delete-button.tsx
import { trpc } from "@/lib/trpc";

export function DeleteButton({ postId }: { postId: string }) {
  const deletePost = trpc.post.delete.useMutation({
    onError: (error) => {
      // error.data.code contains tRPC error code
      switch (error.data?.code) {
        case "NOT_FOUND":
          toast.error("Post no longer exists");
          break;
        case "FORBIDDEN":
          toast.error("You cannot delete this post");
          break;
        case "UNAUTHORIZED":
          toast.error("Please log in to continue");
          break;
        default:
          toast.error("Something went wrong");
      }
    },
  });

  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      Delete
    </button>
  );
}

// Named export
export { DeleteButton };
```

**Why good:** Standardized error codes enable consistent client handling, cause preserves original stack for debugging, error.data provides typed access to error details

</patterns>

---

<integration>

## Integration Guide

**Works with:**

- **React Query (@tanstack/react-query)**: tRPC's React integration is built on React Query, providing caching, background refetching, and optimistic updates
- **Zod**: Input validation schemas that provide both runtime validation and TypeScript type inference
- **Next.js**: First-class adapters for both App Router and Pages Router
- **TypeScript monorepos**: Types flow from backend to frontend via AppRouter type export

**Replaces / Conflicts with:**

- **REST with OpenAPI code generation**: tRPC eliminates need for schema files and code generation
- **GraphQL**: tRPC provides similar DX without GraphQL's complexity for TypeScript-only stacks
- **Custom fetch wrappers**: tRPC handles serialization, batching, and type safety automatically

</integration>

---

<critical_reminders>

## CRITICAL REMINDERS

**(You MUST export `AppRouter` type from your tRPC router for client-side type inference)**

**(You MUST wrap async state mutations in `runInAction()` when using tRPC with reactive stores)**

**(You MUST use `TRPCError` with appropriate error codes - never throw raw Error objects)**

**(You MUST use Zod for input validation on ALL procedures accepting user input)**

**(You MUST use named constants for ALL timeout/retry values - NO magic numbers)**

**Failure to follow these rules will break type safety, cause runtime errors, and defeat the purpose of using tRPC.**

</critical_reminders>

Overview

This skill teaches tRPC type-safe API patterns and React Query integration for full-stack TypeScript projects. It focuses on router/procedure setup, Zod input validation, context and middleware for auth, and TanStack React Query (v11) integration for caching and optimistic updates. The guidance emphasizes conventions that guarantee end-to-end type safety and predictable runtime behavior.

How this skill works

The skill inspects tRPC initialization, router composition, procedure definitions, Zod schemas, TRPCError usage, and client integration hooks/contexts. It explains where to export the AppRouter type for client-side inference, how to create protected procedures with middleware, and how to wire tRPC into React Query with httpBatchLink and transformers. It also highlights v11-specific requirements such as placing the transformer inside httpBatchLink and using named constants for retry/timeouts.

When to use it

  • Building APIs inside TypeScript monorepos with shared types
  • Full-stack TypeScript apps (Next.js, T3 Stack) needing end-to-end type safety
  • Projects requiring tight React Query integration for caching and optimistic updates
  • When you control both client and server in TypeScript and want zero schema duplication
  • When you want composable middleware for auth and role-based access control

Best practices

  • Always export type AppRouter from your composed router for client-side inference
  • Validate all procedure inputs with Zod and export schemas as constants
  • Use TRPCError with appropriate codes; never throw raw Error objects
  • Wrap async state mutations in runInAction() when using reactive stores
  • Use named constants for all timeout and retry values; avoid magic numbers
  • Place transformer inside httpBatchLink for tRPC v11 (critical)

Example use cases

  • Monorepo API serving a Next.js frontend with shared types and autocompletion
  • Protected CRUD endpoints with middleware that narrows context to authenticated users
  • Client-side components using typed hooks or createTRPCContext with React Query for caching and optimistic updates
  • Admin-only router sections composed with admin middleware and explicit TRPCError codes
  • Batching low-latency requests with httpBatchLink and superjson transformer

FAQ

What must I export for client type inference?

Export the AppRouter type from your composed router (export type AppRouter = typeof appRouter). The client imports this type for full end-to-end inference.

Where should the transformer be configured in v11?

In tRPC v11 the transformer must be provided inside httpBatchLink, not at createTRPCClient level. Putting it at the client level will cause runtime errors.