
Web Data Fetching Trpc
- 40 installs
- 19 repo stars
- Updated July 19, 2026
- agents-inc/skills
web-data-fetching-trpc is a Claude Code skill that generates tRPC end-to-end type-safe API patterns including routers, procedures, Zod validation, middleware, and React Query integration.
About
A Claude Code skill for tRPC, which shares TypeScript types directly from server to client for end-to-end type safety without code generation. It covers router and procedure definition, Zod input validation, context and middleware for auth, TRPCError handling, and React Query integration. A developer uses it in full-stack TypeScript monorepos where types should flow automatically from backend to frontend. It targets the v11 stable release.
- tRPC end-to-end type-safe API patterns for TypeScript apps
- Procedures, Zod input validation, middleware, TRPCError
- React Query integration and v11 conventions
Web Data Fetching Trpc by the numbers
- 40 all-time installs (skills.sh)
- Ranked #1,376 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
web-data-fetching-trpc capabilities & compatibility
- Capabilities
- type safe api · data fetching · input validation · auth middleware · optimistic updates
- Use cases
- frontend · api development
What web-data-fetching-trpc says it does
tRPC provides end-to-end type safety by sharing TypeScript types directly from server to client -- no code generation, no schema files.
tRPC type-safe API patterns, procedures, middleware, React Query integration
npx skills add https://github.com/agents-inc/skills --skill web-data-fetching-trpcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 40 |
|---|---|
| repo stars | ★ 19 |
| Last updated | July 19, 2026 |
| Repository | agents-inc/skills ↗ |
What it does
Build an end-to-end type-safe tRPC data layer for a full-stack TypeScript app.
Who is it for?
Full-stack TypeScript monorepos wanting type-safe client-server data without codegen
Skip if: Public APIs consumed by third parties or non-TypeScript clients
When should I use this skill?
Defining tRPC routers, procedures, or wiring React Query to tRPC
What you get
tRPC router with typed procedures, Zod validation, and React Query hooks
- tRPC router and procedures
- Zod input schemas
- auth middleware
By the numbers
- 7 resource files (core, middleware, infinite-queries, optimistic-updates, subscriptions, file-uploads, reference)
Files
tRPC Type-Safe API Patterns
Quick Guide: tRPC provides end-to-end type safety by sharing TypeScript types directly from server to client -- no code generation, no schema files. ExportAppRoutertype from your router (this is the key bridge). Use Zod for input validation,TRPCErrorwith proper codes for errors, and middleware for auth. v11 is the current stable version: transformer goes insidehttpBatchLink(), subscriptions use async generators (notobservable()), and@trpc/tanstack-react-queryis the recommended React integration.
---
<critical_requirements>
CRITICAL: Before Using This Skill
(You MUST export `AppRouter` type from your tRPC router for client-side type inference)
(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 place transformer inside `httpBatchLink()` in v11 -- NOT at client level)
</critical_requirements>
---
Auto-detection: tRPC router, initTRPC, createTRPCClient, createTRPCContext, @trpc/server, @trpc/client, @trpc/react-query, @trpc/tanstack-react-query, TRPCError, procedure, publicProcedure, protectedProcedure, query, mutation, subscription, httpBatchLink, queryOptions, mutationOptions, useTRPC
When to use:
- Building APIs in TypeScript monorepos with shared types
- End-to-end type safety without code generation
- Full-stack TypeScript applications where both client and server are TypeScript
- Projects where types should flow automatically from backend to frontend
When NOT to use:
- Public APIs consumed by third parties (use OpenAPI/REST)
- Non-TypeScript clients (mobile apps, other languages)
- Need HTTP caching at CDN level (tRPC uses POST by default)
- GraphQL requirements with partial queries
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 integration via
@trpc/tanstack-react-query(recommended) or@trpc/react-query(classic) - Optimistic updates, infinite queries, subscriptions
Detailed Resources:
- examples/core.md - Router setup, CRUD, provider, type inference, queryOptions
- examples/middleware.md - Logging, rate limiting, org-scoped access
- examples/infinite-queries.md - Cursor pagination, infinite scroll
- examples/optimistic-updates.md - Optimistic updates with rollback
- examples/subscriptions.md - Async generator subscriptions, SSE
- examples/file-uploads.md - FormData file uploads (v11+)
- reference.md - Decision frameworks, error codes, anti-patterns, v11 migration
---
<philosophy>
Philosophy
tRPC eliminates API layer friction by sharing types directly between server and client. No schemas to write, no code to generate -- 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
- Built on TanStack Query: Full caching, invalidation, and optimistic updates via React Query
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
- Uses POST by default -- no HTTP caching without configuration
</philosophy>
---
<patterns>
Core Patterns
Pattern 1: tRPC Initialization and Router Setup
Initialize tRPC once per application. Export the router and procedure factories.
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import type { Context } from "./context";
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;Why good: Single initialization point, error formatter provides structured Zod errors to client, exported factories enable composition across router files
See examples/core.md Pattern 1 for complete router and context factory.
---
Pattern 2: Procedures with Zod Input Validation
Zod schemas provide runtime validation AND TypeScript inference from a single source.
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
export const userRouter = router({
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input, ctx }) => {
// input is typed: { email: string; name: string }
return ctx.db.user.create({ data: input });
}),
});// BAD: No input validation -- input is 'unknown'
publicProcedure.mutation(async ({ input }) => {
return ctx.db.user.create({ data: input as any }); // Dangerous!
});Why bad: Without Zod validation, input is unknown type, no runtime validation, injection risks, as any defeats TypeScript
See examples/core.md Pattern 2 for complete CRUD router.
---
Pattern 3: Authentication Middleware
Middleware narrows context types -- ctx.user becomes non-nullable after auth middleware.
const isAuthenticated = middleware(async ({ ctx, next }) => {
if (!ctx.session || !ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, session: ctx.session, user: ctx.user } });
});
export const protectedProcedure = publicProcedure.use(isAuthenticated);Why good: Auth enforced at procedure definition, TypeScript narrows ctx.user to non-nullable, eliminates duplicated if-checks in every handler
See examples/middleware.md for logging, rate limiting, and org-scoped access patterns.
---
Pattern 4: AppRouter Type Export
This is the KEY to tRPC's type safety. Export the router type for client-side inference.
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// THIS IS ESSENTIAL -- without it, clients have no type inference
export type AppRouter = typeof appRouter;Use inferRouterInputs/inferRouterOutputs for extracting procedure types:
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
type RouterInputs = inferRouterInputs<AppRouter>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
// Extract specific type
type User = RouterOutputs["user"]["getById"];See examples/core.md Pattern 4 for complete type inference utilities.
---
Pattern 5: React Integration (v11 Recommended)
v11 introduces @trpc/tanstack-react-query with queryOptions/mutationOptions factories that work directly with TanStack Query hooks.
// Setup: createTRPCContext provides typed hooks
import { createTRPCContext } from "@trpc/tanstack-react-query";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
// Usage: standard TanStack Query hooks with tRPC type safety
const trpc = useTRPC();
const { data } = useQuery(trpc.user.getById.queryOptions({ id: userId }));v11 CRITICAL: Transformer must be inside httpBatchLink(), NOT at createTRPCClient() level.
// BAD: v11 error
createTRPCClient({ transformer: superjson, links: [...] });
// GOOD: transformer inside the link
httpBatchLink({ url: "/api/trpc", transformer: superjson });See examples/core.md Patterns 3 and 5 for complete provider and component setup.
---
Pattern 6: Error Handling with TRPCError
Use standardized error codes that map to HTTP status codes.
// Server: throw TRPCError with appropriate code
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete",
cause: error, // Preserves original stack trace
});// Client: typed error handling
const trpc = useTRPC();
const deletePost = useMutation({
...trpc.post.delete.mutationOptions(),
onError: (error) => {
switch (error.data?.code) {
case "NOT_FOUND":
toast.error("Not found");
break;
case "FORBIDDEN":
toast.error("Not allowed");
break;
}
},
});See reference.md for complete error code table with HTTP status mappings.
---
Pattern 7: Optimistic Updates
Cancel queries, snapshot state, optimistically update, rollback on error, invalidate on settle.
const trpc = useTRPC();
const queryClient = useQueryClient();
const toggleTodo = useMutation({
...trpc.todo.toggle.mutationOptions(),
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: trpc.todo.list.queryKey() });
const previousTodos = queryClient.getQueryData(trpc.todo.list.queryKey());
queryClient.setQueryData(trpc.todo.list.queryKey(), (old: any) =>
old?.map((t: any) =>
t.id === id ? { ...t, completed: !t.completed } : t,
),
);
return { previousTodos };
},
onError: (err, vars, context) => {
if (context?.previousTodos)
queryClient.setQueryData(
trpc.todo.list.queryKey(),
context.previousTodos,
);
},
onSettled: () =>
queryClient.invalidateQueries({ queryKey: trpc.todo.list.queryKey() }),
});Why good: Immediate UI feedback, automatic rollback on failure, eventual consistency via invalidation
See examples/optimistic-updates.md for complete pattern with like button example.
</patterns>
---
<red_flags>
RED FLAGS
High Priority Issues:
- Missing `export type AppRouter` -- clients have no type inference, defeats purpose of tRPC
- Raw `throw new Error()` -- should use
TRPCErrorwith appropriate code for HTTP mapping - Procedures without `.input()` validation -- no runtime validation, type is
unknown - Auth checks in procedure body -- should use middleware for protected procedures
- Transformer at client level in v11 -- must be inside
httpBatchLink(), not atcreateTRPCClient()level
Medium Priority Issues:
- Missing SuperJSON transformer -- Date/Map/Set won't serialize correctly
- No error formatter -- Zod errors should be formatted for better client DX
- Optimistic updates without rollback -- must include
onErrorhandler to restore previous state - Using `observable()` for subscriptions -- v11 uses async generators;
observable()is the v10 pattern - Using `rawInput` in middleware -- v11 changed to
getRawInput()function
Gotchas & Edge Cases:
httpBatchLinkcombines requests -- all batched requests share the same HTTP status code- SuperJSON transformer must be configured on BOTH client and server
- Context is created per-request -- don't store mutable state in context
- Middleware runs in order -- auth middleware should come before rate limiting
- Query keys are auto-generated -- use
queryKey()method (v11) orgetQueryKey()for manual access - Subscription reconnection with
tracked()requireslastEventIdin input schema - Don't retry mutations (
retry: false) -- retrying writes can cause duplicates
</red_flags>
---
<critical_reminders>
CRITICAL REMINDERS
(You MUST export `AppRouter` type from your tRPC router for client-side type inference)
(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 place transformer inside `httpBatchLink()` in v11 -- NOT at client level)
Failure to follow these rules will break type safety, cause runtime errors, and defeat the purpose of using tRPC.
</critical_reminders>
tRPC Type-Safe API - Core Examples
Essential patterns for tRPC setup and usage. See SKILL.md for core concepts.
Extended Examples:
- middleware.md - Logging, Rate Limiting, Org-Scoped Access
- infinite-queries.md - Cursor Pagination, Infinite Scroll
- optimistic-updates.md - Optimistic Updates with Rollback
- subscriptions.md - Server-Sent Events
- file-uploads.md - FormData File Uploads (tRPC v11+)
---
Pattern 1: Complete Router Setup
Server-Side Initialization
// packages/api/src/trpc/index.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import type { Context } from "./context";
const t = initTRPC.context<Context>().create({
// SuperJSON enables Date, Map, Set serialization
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;Context Factory
// packages/api/src/trpc/context.ts
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
export async function createContext(opts: FetchCreateContextFnOptions) {
// Use your auth solution to validate the request
const session = await validateSession(opts.req);
return {
db, // Your database client
session,
user: session?.user ?? null,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;Bad Example - Missing Type Export
// packages/api/src/root.ts
// BAD: AppRouter type not exported - clients have no type safety
const appRouter = router({
user: userRouter,
});
// Missing: export type AppRouter = typeof appRouter;Why bad: Without AppRouter type export, client has no type inference, autocomplete, or compile-time safety - defeats the entire purpose of tRPC
---
Pattern 2: CRUD Router with Full Validation
Complete User Router
// packages/api/src/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
// Input schema constants
const userIdSchema = z.object({
id: z.string().uuid(),
});
const createUserSchema = z.object({
email: z.string().email("Invalid email format"),
name: z.string().min(1, "Name required").max(100, "Name too long"),
bio: z.string().max(500).optional(),
});
const updateUserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
});
const listUsersSchema = z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().uuid().optional(),
});
export const userRouter = router({
// List with cursor pagination
list: publicProcedure.input(listUsersSchema).query(async ({ input, ctx }) => {
const users = await ctx.db.user.findMany({
take: input.limit + 1, // Fetch one extra for cursor
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: "desc" },
});
let nextCursor: string | undefined;
if (users.length > input.limit) {
const nextItem = users.pop();
nextCursor = nextItem?.id;
}
return { users, nextCursor };
}),
// Get by ID
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 ${input.id} not found`,
});
}
return user;
}),
// Create (protected)
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input, ctx }) => {
// Check for duplicate email
const existing = await ctx.db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already in use",
});
}
return ctx.db.user.create({ data: input });
}),
// Update (protected, owner only)
update: protectedProcedure
.input(updateUserSchema)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
// Verify ownership
const user = await ctx.db.user.findUnique({ where: { id } });
if (!user) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (user.id !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Can only update your own profile",
});
}
return ctx.db.user.update({ where: { id }, data });
}),
// Delete (protected, owner only)
delete: protectedProcedure
.input(userIdSchema)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (user.id !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
}
await ctx.db.user.delete({ where: { id: input.id } });
return { success: true };
}),
});
// Named export
export { userRouter };Bad Example - No Validation
// BAD: No input validation
const userRouter = router({
create: publicProcedure.mutation(async ({ input, ctx }) => {
// input is 'unknown' - no type safety
return ctx.db.user.create({ data: input as any }); // Dangerous!
}),
});Why bad: Without Zod validation, input is unknown type, no runtime validation, SQL injection and invalid data risks, any cast defeats TypeScript safety
---
Pattern 3: React Query Integration (tRPC v11)
Provider Setup (New TanStack Integration - Recommended)
// apps/client/lib/trpc.ts
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../api"; // Your shared API package
// v11: Create typed context providers and hooks
export const { TRPCProvider, useTRPC, useTRPCClient } =
createTRPCContext<AppRouter>();
export { TRPCProvider, useTRPC, useTRPCClient };// apps/client/lib/trpc-provider.tsx
// Mark as client component if using an SSR framework
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
import superjson from "superjson";
import { TRPCProvider } from "./trpc";
import type { AppRouter } from "../../api"; // Your shared API package
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const DEFAULT_RETRY_ATTEMPTS = 3;
const isDevelopment = process.env.NODE_ENV === "development";
function getBaseUrl() {
if (typeof window !== "undefined") return ""; // Browser - relative URL
// Use your deployment URL env var (e.g., VERCEL_URL, RAILWAY_URL, etc.)
if (process.env.DEPLOY_URL) return `https://${process.env.DEPLOY_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: FIVE_MINUTES_MS,
retry: isDevelopment ? false : 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: [
// Logger in development
loggerLink({
enabled: () => isDevelopment,
}),
// Batch HTTP requests
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
// v11 CRITICAL: transformer goes INSIDE the link
transformer: superjson,
headers() {
return {
"x-trpc-source": "react",
};
},
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
// Named export
export { AppTRPCProvider };Bad Example - Transformer in Wrong Location
// BAD: v11 error - transformer at client level
const trpcClient = createTRPCClient<AppRouter>({
transformer: superjson, // WRONG - causes error in v11
links: [httpBatchLink({ url: "/api/trpc" })],
});Why bad: tRPC v11 moved transformer to links - placing it at client level causes "The transformer property has moved to httpLink/httpBatchLink/wsLink" error
Bad Example - Magic Numbers
// BAD: Magic numbers obscure configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 300000, // What's this? 5 minutes? 3 minutes?
retry: 3, // Why 3?
},
},
});Why bad: Magic numbers require code archaeology to understand, makes policy changes difficult, violates project conventions
---
Pattern 4: TypeScript Inference Utilities
Extracting Types from Router
// packages/api/src/types.ts
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "./root";
// Infer all input types
export type RouterInputs = inferRouterInputs<AppRouter>;
// Infer all output types
export type RouterOutputs = inferRouterOutputs<AppRouter>;
// Named exports for type definitions
export type { RouterInputs, RouterOutputs };Using Inferred Types in Components
// apps/client/components/user-card.tsx
import type { RouterOutputs } from "../../api"; // Your shared API package
// Extract specific procedure output type
type User = RouterOutputs["user"]["getById"];
interface UserCardProps {
user: User;
}
export function UserCard({ user }: UserCardProps) {
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
<span>Joined {user.createdAt.toLocaleDateString()}</span>
</div>
);
}
// Named export
export { UserCard };Bad Example - Manual Type Definitions
// BAD: Manual types drift from backend
interface User {
id: string;
name: string;
email: string;
// Missing createdAt! Runtime error when accessed
}
function UserCard({ user }: { user: User }) {
// TypeScript won't catch this - createdAt doesn't exist in manual type
return <span>{user.createdAt.toLocaleDateString()}</span>;
}Why bad: Manual types diverge from backend causing runtime errors, defeats tRPC's automatic type inference, double maintenance burden
---
Pattern 5: v11 queryOptions/mutationOptions Pattern
Using New v11 TanStack Integration API
// apps/client/components/user-list.tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../lib/trpc";
export function UserList() {
// useTRPC from createTRPCContext provides typed procedure access
const trpc = useTRPC();
const queryClient = useQueryClient();
// v11: queryOptions factory creates React Query compatible options
const usersQuery = useQuery(trpc.user.list.queryOptions({ limit: 10 }));
// v11: mutationOptions factory with custom onSuccess
const createUser = useMutation({
...trpc.user.create.mutationOptions(),
onSuccess: () => {
// v11: queryKey for type-safe invalidation
queryClient.invalidateQueries({
queryKey: trpc.user.list.queryKey(),
});
},
});
return (
<div>
{usersQuery.data?.users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={() => createUser.mutate({ email: "new@example.com", name: "New User" })}>
Add User
</button>
</div>
);
}
// Named export
export { UserList };Benefits of v11 TanStack Integration
- Direct React Query hook usage (
useQuery,useMutationfrom TanStack) - `queryOptions()` returns complete query configuration
- `mutationOptions()` returns complete mutation configuration
- `queryKey()` provides type-safe query keys for invalidation
- Easier prefetching with
queryClient.prefetchQuery(trpc.x.queryOptions()) - React Compiler compatible - follows hooks rules properly
- Lower learning curve - use standard TanStack Query patterns
---
tRPC File Uploads with FormData
Native file upload support in tRPC v11+. See core.md for setup patterns.
Prerequisites: Understand Pattern 1 (Router Setup) and Pattern 2 (CRUD Router) from core examples first.
Note: File upload support requires tRPC v11+. This pattern is self-contained and can be added to any tRPC setup.
---
Server-Side File Handler (tRPC v11+)
// packages/api/src/routers/upload.ts
import { z } from "zod";
import { router, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
export const uploadRouter = router({
// tRPC v11 supports FormData natively
uploadAvatar: protectedProcedure
.input(
z.object({
file: z.instanceof(File),
}),
)
.mutation(async ({ input, ctx }) => {
const { file } = input;
// Validate file size
if (file.size > MAX_FILE_SIZE_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `File too large. Max size: ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB`,
});
}
// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid file type. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}`,
});
}
// Upload to storage (e.g., S3)
const url = await uploadToStorage(file, ctx.user.id);
// Update user avatar
await ctx.db.user.update({
where: { id: ctx.user.id },
data: { avatarUrl: url },
});
return { url };
}),
});
// Named export
export { uploadRouter };---
Client-Side File Upload
// apps/client/components/avatar-upload.tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../lib/trpc";
import { useState } from "react";
const MAX_FILE_SIZE_MB = 5;
export function AvatarUpload() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [preview, setPreview] = useState<string | null>(null);
const uploadMutation = useMutation({
...trpc.upload.uploadAvatar.mutationOptions(),
onSuccess: () => {
toast.success("Avatar uploaded!");
queryClient.invalidateQueries({ queryKey: trpc.user.me.queryKey() });
},
onError: (error) => {
toast.error(error.message);
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Client-side preview
const reader = new FileReader();
reader.onloadend = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
// Upload
uploadMutation.mutate({ file });
};
return (
<div>
{preview && <img src={preview} alt="Preview" />}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
disabled={uploadMutation.isPending}
/>
{uploadMutation.isPending && <Spinner />}
</div>
);
}
// Named export
export { AvatarUpload };---
tRPC Infinite Query Pagination
Cursor-based pagination with infinite scroll. See core.md for setup patterns.
Prerequisites: Understand Pattern 3 (React Query Integration) from core examples first.
---
Server-Side Cursor Pagination
// packages/api/src/routers/post.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
const infinitePostsSchema = z.object({
limit: z.number().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
cursor: z.string().uuid().optional(),
filter: z.enum(["all", "published", "draft"]).default("all"),
});
export const postRouter = router({
infinite: publicProcedure
.input(infinitePostsSchema)
.query(async ({ input, ctx }) => {
const { limit, cursor, filter } = input;
const where = filter !== "all" ? { status: filter } : {};
const posts = await ctx.db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
where,
orderBy: { createdAt: "desc" },
include: { author: { select: { name: true, avatar: true } } },
});
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return { posts, nextCursor };
}),
});
// Named export
export { postRouter };---
Client-Side Infinite Query
// apps/client/components/post-feed.tsx
import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "../lib/trpc";
import { useEffect, useRef, useCallback } from "react";
const PAGE_SIZE = 20;
export function PostFeed() {
const trpc = useTRPC();
const sentinelRef = useRef<HTMLDivElement>(null);
// v11: infiniteQueryOptions factory with standard useInfiniteQuery
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
error,
} = useInfiniteQuery(
trpc.post.infinite.infiniteQueryOptions(
{ limit: PAGE_SIZE },
{ getNextPageParam: (lastPage) => lastPage.nextCursor },
),
);
const loadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// IntersectionObserver for infinite scroll
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin: "100px" },
);
observer.observe(el);
return () => observer.unobserve(el);
}, [loadMore]);
if (isPending) return <PostFeedSkeleton />;
if (error) return <Error message={error.message} />;
return (
<div>
{data.pages.map((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
{/* Sentinel element triggers load when visible */}
<div ref={sentinelRef}>
{isFetchingNextPage && <Spinner />}
{!hasNextPage && <p>No more posts</p>}
</div>
</div>
);
}
// Named export
export { PostFeed };---
tRPC Middleware Patterns
Middleware for logging, rate limiting, and access control. See core.md for setup patterns.
Prerequisites: Understand Pattern 1 (Router Setup) from core examples first.
---
Logging Middleware
// packages/api/src/trpc/middleware/logging.ts
import { middleware } from "../index";
const REQUEST_SLOW_THRESHOLD_MS = 1000;
export const loggingMiddleware = middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const durationMs = Date.now() - start;
if (durationMs > REQUEST_SLOW_THRESHOLD_MS) {
console.warn(`Slow ${type} ${path}: ${durationMs}ms`);
}
return result;
});
// Named export
export { loggingMiddleware };---
Rate Limiting Middleware
// packages/api/src/trpc/middleware/rate-limit.ts
import { TRPCError } from "@trpc/server";
import { middleware } from "../index";
const RATE_LIMIT_REQUESTS = 100;
const RATE_LIMIT_WINDOW_SECONDS = 60;
// Use your rate limiting solution (e.g., in-memory, Redis-backed, or a managed service)
// This example uses a simple in-memory store for illustration
const requestCounts = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(identifier: string): {
success: boolean;
resetAt: number;
} {
const now = Date.now();
const entry = requestCounts.get(identifier);
if (!entry || now > entry.resetAt) {
requestCounts.set(identifier, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_SECONDS * 1000,
});
return { success: true, resetAt: now + RATE_LIMIT_WINDOW_SECONDS * 1000 };
}
entry.count++;
if (entry.count > RATE_LIMIT_REQUESTS) {
return { success: false, resetAt: entry.resetAt };
}
return { success: true, resetAt: entry.resetAt };
}
export const rateLimitMiddleware = middleware(async ({ ctx, next }) => {
const identifier = ctx.user?.id ?? ctx.ip ?? "anonymous";
const { success, resetAt } = checkRateLimit(identifier);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again in ${Math.ceil((resetAt - Date.now()) / 1000)} seconds`,
});
}
return next();
});
// Named export
export { rateLimitMiddleware };---
Organization-Scoped Access
// packages/api/src/trpc/middleware/org-access.ts
import { TRPCError } from "@trpc/server";
import { middleware, protectedProcedure } from "../index";
// Middleware that validates org membership
// v11: rawInput changed to getRawInput() (async function)
const withOrgAccess = middleware(async ({ ctx, getRawInput, next }) => {
const input = (await getRawInput()) as { orgId: string };
if (!input.orgId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "orgId is required",
});
}
const membership = await ctx.db.orgMembership.findUnique({
where: {
orgId_userId: {
orgId: input.orgId,
userId: ctx.user.id,
},
},
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not a member of this organization",
});
}
return next({
ctx: {
...ctx,
orgMembership: membership,
},
});
});
// Create org-scoped procedure
export const orgProcedure = protectedProcedure.use(withOrgAccess);
// Named export
export { orgProcedure };---
Bad Example - Checking Auth in Every Handler
// BAD: Duplicated auth checks in every procedure
const postRouter = router({
create: publicProcedure.mutation(async ({ ctx }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); // Repeated!
// ...
}),
update: publicProcedure.mutation(async ({ ctx }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); // Repeated!
// ...
}),
delete: publicProcedure.mutation(async ({ ctx }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); // Repeated!
// ...
}),
});Why bad: Duplicated auth logic is error-prone (easy to forget), no TypeScript narrowing (ctx.user still nullable), middleware provides compile-time safety
---
tRPC Optimistic Updates with Rollback
Instant UI feedback with automatic rollback on failure. See core.md for setup patterns.
Prerequisites: Understand Pattern 3 (React Query Integration) from core examples first.
---
Complete Optimistic Pattern
// apps/client/components/like-button.tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../lib/trpc";
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const toggleLike = useMutation({
...trpc.post.toggleLike.mutationOptions(),
// Optimistic update BEFORE server response
onMutate: async ({ postId }) => {
// Cancel in-flight queries to prevent overwriting optimistic update
await queryClient.cancelQueries({
queryKey: trpc.post.getById.queryKey({ id: postId }),
});
// Snapshot current state for rollback
const previousPost = queryClient.getQueryData(
trpc.post.getById.queryKey({ id: postId }),
);
// Optimistically update the cache
queryClient.setQueryData(
trpc.post.getById.queryKey({ id: postId }),
(old: any) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
},
);
// Return context for rollback
return { previousPost };
},
// Rollback on error
onError: (err, { postId }, context) => {
if (context?.previousPost) {
queryClient.setQueryData(
trpc.post.getById.queryKey({ id: postId }),
context.previousPost,
);
}
toast.error("Failed to update like");
},
// Always refetch to ensure consistency
onSettled: (data, error, { postId }) => {
queryClient.invalidateQueries({
queryKey: trpc.post.getById.queryKey({ id: postId }),
});
},
});
const post = useQuery(trpc.post.getById.queryOptions({ id: postId }));
const liked = post.data?.liked ?? initialLiked;
const count = post.data?.likeCount ?? initialCount;
return (
<button
onClick={() => toggleLike.mutate({ postId })}
disabled={toggleLike.isPending}
aria-pressed={liked}
>
{liked ? "Unlike" : "Like"} ({count})
</button>
);
}
// Named export
export { LikeButton };---
Bad Example - No Rollback
// BAD: Optimistic update without rollback
const toggleLike = useMutation({
...trpc.post.toggleLike.mutationOptions(),
onMutate: async ({ postId }) => {
// Updates cache but no snapshot for rollback!
queryClient.setQueryData(
trpc.post.getById.queryKey({ id: postId }),
(old: any) => ({ ...old!, liked: !old!.liked }),
);
// Missing: return { previousPost }
},
// Missing: onError rollback
});Why bad: If server fails, UI shows incorrect state, no way to restore previous data, user sees inconsistent information
---
tRPC Subscriptions with Server-Sent Events
Real-time updates using async generator subscriptions (v11). See core.md for setup patterns.
Prerequisites: Understand Pattern 1 (Router Setup) from core examples first.
---
Server-Side Subscription (v11 Async Generator)
// packages/api/src/routers/notification.ts
import { z } from "zod";
import { router, protectedProcedure } from "../trpc";
import { tracked } from "@trpc/server";
import { EventEmitter, on } from "events";
// Create typed event emitter
const ee = new EventEmitter();
interface NotificationEvent {
id: string;
userId: string;
message: string;
type: "info" | "warning" | "error";
timestamp: Date;
}
export const notificationRouter = router({
// v11: async generator subscription with tracked() for reconnection
onNotification: protectedProcedure
.input(
z
.object({
// lastEventId enables automatic reconnection resumption
lastEventId: z.string().nullish(),
})
.optional(),
)
.subscription(async function* ({ ctx, input, signal }) {
// Listen for events, respecting abort signal for cleanup
for await (const [data] of on(ee, "notification", { signal })) {
const notification = data as NotificationEvent;
// Only emit to the subscribed user
if (notification.userId === ctx.user.id) {
// tracked() sends event ID -- client auto-resumes from here on reconnect
yield tracked(notification.id, notification);
}
}
}),
// Trigger notification (for testing/internal use)
send: protectedProcedure
.input(
z.object({
userId: z.string().uuid(),
message: z.string(),
type: z.enum(["info", "warning", "error"]),
}),
)
.mutation(({ input }) => {
ee.emit("notification", {
id: crypto.randomUUID(),
...input,
timestamp: new Date(),
} satisfies NotificationEvent);
return { sent: true };
}),
});
// Named export
export { notificationRouter };---
Client-Side Subscription
// apps/client/components/notification-listener.tsx
import { useTRPC } from "../lib/trpc";
import { useSubscription } from "@trpc/tanstack-react-query";
export function NotificationListener() {
const trpc = useTRPC();
// Subscribe to real-time notifications via SSE
useSubscription(
trpc.notification.onNotification.subscriptionOptions(undefined, {
onData: (notification) => {
toast[notification.type](notification.message);
},
onError: (err) => {
console.error("Subscription error:", err);
},
}),
);
return null; // Renderless component
}
// Named export
export { NotificationListener };---
Bad Example - Using observable() (v10 Pattern)
// BAD: v10 observable pattern -- deprecated in v11
import { observable } from "@trpc/server/observable";
onNotification: protectedProcedure.subscription(({ ctx }) => {
return observable<NotificationEvent>((emit) => {
const handler = (data: NotificationEvent) => emit.next(data);
ee.on("notification", handler);
return () => ee.off("notification", handler);
});
});Why bad: observable() is the v10 pattern; v11 uses async generators with signal for cleanup and tracked() for automatic reconnection -- provides better resilience and type safety
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/agents-inc/cli/main/src/schemas/metadata.schema.json
category: web-server-state
slug: trpc
domain: web
author: "@vince"
displayName: tRPC
cliDescription: Type-safe API layer
usageGuidance: Use when building type-safe APIs with tRPC procedures and React Query.
tRPC Type-Safe API - Reference
Decision frameworks, anti-patterns, error codes, and v11 migration. See SKILL.md for core concepts.
---
<decision_framework>
Decision Framework
When to Use tRPC vs Alternatives
Need API for TypeScript monorepo?
├─ Is client TypeScript? (browser, Node, React Native)
│ ├─ YES → Is API public (third-party consumers)?
│ │ ├─ YES → Use OpenAPI/REST (need docs & polyglot support)
│ │ └─ NO → Use tRPC
│ └─ NO → Use OpenAPI/REST (non-TS clients)
├─ Need GraphQL features? (partial queries, subscriptions at scale)
│ └─ YES → Use GraphQL
└─ Need HTTP caching at CDN?
└─ YES → Use REST (tRPC uses POST by default)Procedure Type Selection
What operation are you implementing?
├─ Reading data (GET semantics)?
│ └─ Use .query()
├─ Writing/modifying data (POST/PUT/DELETE semantics)?
│ └─ Use .mutation()
└─ Real-time updates?
└─ Use .subscription() with async generatorsAuthentication Strategy
Need authentication?
├─ Session-based (cookies)?
│ └─ Extract session in createContext, use protectedProcedure
├─ Token-based (JWT in header)?
│ └─ Validate token in createContext, use protectedProcedure
├─ API key?
│ └─ Validate in middleware, throw UNAUTHORIZED on failure
└─ Public endpoints?
└─ Use publicProcedureError Handling Strategy
What type of error?
├─ Validation failure (bad input)?
│ └─ Let Zod throw -- tRPC formats automatically
├─ Resource not found?
│ └─ throw new TRPCError({ code: "NOT_FOUND" })
├─ Permission denied?
│ └─ throw new TRPCError({ code: "FORBIDDEN" })
├─ Not authenticated?
│ └─ throw new TRPCError({ code: "UNAUTHORIZED" })
├─ Rate limited?
│ └─ throw new TRPCError({ code: "TOO_MANY_REQUESTS" })
└─ Unknown server error?
└─ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause: originalError })Cache Invalidation Strategy
After mutation, what to invalidate?
├─ Single item changed?
│ └─ utils.router.procedure.invalidate({ id })
├─ List may have changed (create/delete)?
│ └─ utils.router.list.invalidate()
├─ Multiple related queries?
│ └─ utils.router.invalidate() // Invalidates all procedures in router
└─ Need immediate UI update?
└─ Use optimistic update with onMutateReact Integration Selection (v11)
Starting new project?
├─ YES → Use @trpc/tanstack-react-query (recommended)
│ createTRPCContext, useTRPC, queryOptions(), mutationOptions()
└─ NO → Already using @trpc/react-query?
├─ YES → Classic integration still works in v11
│ trpc.x.useQuery(), trpc.x.useMutation()
└─ Gradually migrate to new integration when convenient</decision_framework>
---
<anti_patterns>
Anti-Patterns
Missing AppRouter Type Export
// BAD: Type not exported -- clients have no type inference
const appRouter = router({ user: userRouter });
// GOOD: Export type for client-side inference
export const appRouter = router({ user: userRouter });
export type AppRouter = typeof appRouter;Raw Error Objects Instead of TRPCError
// BAD: Raw error has no code or HTTP mapping
throw new Error("User not found");
// GOOD: TRPCError maps to HTTP 404
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });No Input Validation
// BAD: input is unknown, no validation
publicProcedure.mutation(async ({ input }) => {
return ctx.db.user.create({ data: input as any });
});
// GOOD: Validated and typed input
publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
return ctx.db.user.create({ data: input });
});Duplicated Auth Checks
// BAD: Repeated in every procedure
create: publicProcedure.mutation(({ ctx }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
}),
// GOOD: Use protectedProcedure with middleware
create: protectedProcedure.mutation(({ ctx }) => {
// ctx.user guaranteed non-null by middleware
}),Manual Type Definitions
// BAD: Manual type will drift from backend
interface User {
id: string;
name: string;
}
// GOOD: Use inferred types
import type { RouterOutputs } from "./api";
type User = RouterOutputs["user"]["getById"];Optimistic Updates Without Rollback
// BAD: No snapshot, no rollback
onMutate: async () => {
queryClient.setQueryData(trpc.todo.list.queryKey(), (old: any) => [...(old ?? []), newTodo]);
};
// GOOD: Full optimistic pattern
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: trpc.todo.list.queryKey() });
const previous = queryClient.getQueryData(trpc.todo.list.queryKey());
queryClient.setQueryData(trpc.todo.list.queryKey(), (old: any) => [...(old ?? []), newTodo]);
return { previous };
},
onError: (err, vars, ctx) => {
if (ctx?.previous) queryClient.setQueryData(trpc.todo.list.queryKey(), ctx.previous);
},v11 Transformer in Wrong Location
// BAD: v11 error -- transformer at client level
createTRPCClient({ transformer: superjson, links: [...] });
// GOOD: transformer inside the link
httpBatchLink({ url: "/api/trpc", transformer: superjson });Using observable() for Subscriptions (v10 Pattern)
// BAD: v10 observable pattern (deprecated in v11)
import { observable } from "@trpc/server/observable";
.subscription(({ ctx }) => {
return observable((emit) => { emit.next(data); });
});
// GOOD: v11 async generator pattern
.subscription(async function* ({ ctx, signal }) {
for await (const data of eventStream({ signal })) {
yield data;
}
});</anti_patterns>
---
Error Code Reference
| tRPC Code | HTTP Status | When to Use |
|---|---|---|
BAD_REQUEST | 400 | Invalid input (beyond Zod validation) |
UNAUTHORIZED | 401 | Not authenticated |
FORBIDDEN | 403 | Authenticated but not permitted |
NOT_FOUND | 404 | Resource doesn't exist |
METHOD_NOT_SUPPORTED | 405 | Wrong HTTP method |
TIMEOUT | 408 | Request timed out |
CONFLICT | 409 | Resource conflict (e.g., duplicate) |
PRECONDITION_FAILED | 412 | Precondition not met |
PAYLOAD_TOO_LARGE | 413 | Request body too large |
UNPROCESSABLE_CONTENT | 422 | Semantic validation failure |
TOO_MANY_REQUESTS | 429 | Rate limited |
CLIENT_CLOSED_REQUEST | 499 | Client disconnected |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
NOT_IMPLEMENTED | 501 | Feature not implemented |
BAD_GATEWAY | 502 | Upstream service error |
SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable |
GATEWAY_TIMEOUT | 504 | Upstream timeout |
---
Performance Optimization
Request Batching
httpBatchLink automatically combines multiple requests made in the same render cycle:
// These 3 calls become 1 HTTP request
const user = useQuery(trpc.user.getById.queryOptions({ id: "1" }));
const posts = useQuery(trpc.post.list.queryOptions());
const comments = useQuery(trpc.comment.recent.queryOptions());Prefetching
// Prefetch on hover for instant navigation
function UserLink({ userId }: { userId: string }) {
const trpc = useTRPC();
const queryClient = useQueryClient();
return (
<a
href={`/user/${userId}`}
onMouseEnter={() => {
queryClient.prefetchQuery(trpc.user.getById.queryOptions({ id: userId }));
}}
>
View Profile
</a>
);
}Selective Invalidation
const queryClient = useQueryClient();
const trpc = useTRPC();
// DON'T: Invalidate everything
queryClient.invalidateQueries(); // Refetches ALL queries
// DO: Invalidate specific queries
queryClient.invalidateQueries({
queryKey: trpc.user.getById.queryKey({ id: userId }),
});
queryClient.invalidateQueries({ queryKey: trpc.post.list.queryKey() });---
tRPC v11 Migration Notes
Breaking Changes from v10
1. Transformer Location Changed (CRITICAL):
// v10 (no longer works in v11)
createTRPCClient({ transformer: superjson, links: [...] });
// v11 (correct -- transformer INSIDE the link)
httpBatchLink({ url: "/api/trpc", transformer: superjson });2. React Query v5 Required: @tanstack/react-query@^5
- Replace
isLoadingwithisPending
3. `rawInput` renamed to `getRawInput()` in middleware:
// v10
const input = opts.rawInput;
// v11
const input = await opts.getRawInput();4. `createTRPCProxyClient` renamed to `createTRPCClient`
5. Subscriptions: Async generators replace observable() pattern
// v11: async generator with signal
.subscription(async function* ({ signal }) {
for await (const data of stream({ signal })) {
yield tracked(data.id, data);
}
});6. Removed: .interop() mode, inferHandlerInput<T>, ProcedureArgs<T>
7. Requirements: TypeScript >= 5.7.2, Node.js 18+
New v11 Features
- `@trpc/tanstack-react-query`: New TanStack-native integration with
queryOptions/mutationOptions - FormData/File Support: Native support for
File,Blob,Uint8Arrayuploads - `httpBatchStreamLink`: Streaming responses for large datasets
- Server-Sent Events:
httpSubscriptionLinkfor SSE subscriptions - `tracked()` helper: Automatic reconnection with event ID resumption
- React Server Components: Prefetch helpers with
createTRPCOptionsProxy
Installation for v11
# New TanStack-native integration (recommended)
npm install @trpc/server@^11 @trpc/client@^11 @trpc/tanstack-react-query @tanstack/react-query@^5
# Classic integration (still supported)
npm install @trpc/server@^11 @trpc/client@^11 @trpc/react-query@^11 @tanstack/react-query@^5Related skills
FAQ
When should I not use tRPC?
For public APIs consumed by third parties, non-TypeScript clients, or when you need CDN-level HTTP caching.
How does tRPC achieve type safety?
By exporting the AppRouter type so the client infers types directly from the server with no code generation.