Skip to main content

tRPC v11: What Changed, Should You Upgrade? 2026

·PkgPulse Team
0

TL;DR

tRPC v11 is a solid upgrade if you're on v10 — React Query v5 integration is better, streaming works, and the API surface is cleaner. But the bigger question in 2026 is "should I use tRPC at all?" For TypeScript monorepos and full-stack apps (Next.js, SvelteKit, Remix): yes, absolutely — end-to-end type safety without code generation is a genuine superpower. For teams that have tried tRPC and found it adds complexity over Server Actions or REST: the alternatives are better than they were. Upgrade from v10 to v11 with confidence; new projects should evaluate tRPC, Server Actions, and REST+Zod to find the right fit.

Key Takeaways

  • React Query v5: tRPC v11 requires React Query v5 — if you're on v4, that's a double migration
  • Streaming procedures: httpSubscription link for server-sent events, observable for complex streaming
  • Improved TypeScript perf: v11 has faster type checking than v10 for large routers
  • New useSuspenseQuery: Suspense-compatible data fetching via tRPC hooks
  • Breaking changes: minor API changes; upgrade path is documented and manageable

What Changed in tRPC v11

// ─── React Query v5 Integration ───
// tRPC v10 used React Query v4 internals
// tRPC v11 requires React Query v5 (@tanstack/react-query ^5.0.0)

// Key difference in v5 query API:
// v4:
const { data, isLoading, isError } = trpc.user.get.useQuery({ id: '123' });

// v5 (same, but error handling changed):
const { data, isPending, error } = trpc.user.get.useQuery({ id: '123' });
// isLoading renamed to isPending in some contexts
// error now typed properly

// ─── Suspense Support ───
// v11 adds first-class Suspense integration:
function UserProfile({ userId }: { userId: string }) {
  // useSuspenseQuery — throws promise if loading, throws error if failed
  const [user] = trpc.user.get.useSuspenseQuery({ id: userId });
  // No data?.name — data is always defined here (Suspense handles the rest)
  return <h1>{user.name}</h1>;
}

// Wrap in Suspense + ErrorBoundary:
<ErrorBoundary fallback={<ErrorMessage />}>
  <Suspense fallback={<Skeleton />}>
    <UserProfile userId="123" />
  </Suspense>
</ErrorBoundary>

// ─── Server-Sent Events (new httpSubscription link) ───
// v10 had WebSocket subscriptions only
// v11 adds HTTP streaming via SSE (simpler, works with Vercel/Cloudflare):

// Server:
const router = t.router({
  liveUpdates: t.procedure
    .input(z.object({ userId: z.string() }))
    .subscription(async function* ({ input }) {
      // Yield values over time
      while (true) {
        const update = await waitForUpdate(input.userId);
        yield update;
        if (update.done) break;
      }
    }),
});

// Client:
const { data } = trpc.liveUpdates.useSubscription(
  { userId: '123' },
  { onData: (data) => console.log('Got update:', data) }
);

The Core tRPC Value Proposition (Still True)

// Why tRPC remains compelling in 2026:
// End-to-end type safety without code generation

// Server — define the API:
import { z } from 'zod';
import { initTRPC, TRPCError } from '@trpc/server';

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

export const appRouter = t.router({
  user: t.router({
    get: t.procedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input, ctx }) => {
        const user = await ctx.db.user.findUnique({ where: { id: input.id } });
        if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
        return user;
      }),

    update: t.procedure
      .input(z.object({
        id: z.string(),
        name: z.string().min(1).max(100),
        email: z.string().email(),
      }))
      .mutation(async ({ input, ctx }) => {
        return ctx.db.user.update({
          where: { id: input.id },
          data: { name: input.name, email: input.email },
        });
      }),
  }),
});

export type AppRouter = typeof appRouter;

// Client — fully typed, no manual type imports:
const utils = trpc.useUtils();
const { data: user } = trpc.user.get.useQuery({ id: '123' });
const updateUser = trpc.user.update.useMutation({
  onSuccess: () => utils.user.get.invalidate({ id: '123' }),
});

// user.name — TypeScript knows the shape
// updateUser.mutate({ id: '123', email: 'bad-email' }) — TypeScript error!
// Rename a field on the server? TypeScript finds every broken client usage.

// What code generation would look like (OpenAPI/GraphQL alternative):
// 1. Define schema in some format
// 2. Run codegen script
// 3. Import generated types
// 4. Keep in sync when schema changes
// tRPC: skip all of this. Types flow automatically via TypeScript inference.

tRPC vs Server Actions (2026)

// The rise of Server Actions as an alternative to tRPC:

// Server Action (Next.js 15):
'use server';
export async function updateUser(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  // No input validation type safety — you cast manually
  await db.user.update({ where: { id: userId }, data: { name, email } });
}

// tRPC mutation:
const updateUser = t.procedure
  .input(z.object({ name: z.string().min(1), email: z.string().email() }))
  .mutation(async ({ input }) => {
    await db.user.update({ where: { id: userId }, data: input });
  });
// input is fully typed AND validated by Zod before your code runs

// When Server Actions beat tRPC:
// → Simple form submissions (the progressive enhancement story is excellent)
// → When you want less client-side complexity
// → When you're already deep in Next.js App Router
// → "Call my backend function from my React component" use case

// When tRPC beats Server Actions:
// → Complex query patterns with filters, pagination, search
// → Multiple client types (web + mobile + CLI)
// → Teams that want full Zod validation on all inputs
// → Apps with complex client-side state (useQuery cache, optimistic updates)
// → When you need the React Query cache layer (stale-while-revalidate, etc.)

// The honest take:
// Server Actions and tRPC solve slightly different problems
// Simple CRUD mutations → Server Actions are simpler
// Complex API layer with multiple consumers → tRPC wins
// Many teams use BOTH: tRPC for queries, Server Actions for mutations

v10 → v11 Migration

# Step 1: Update dependencies
npm install @trpc/server@^11 @trpc/client@^11 @trpc/react-query@^11
npm install @tanstack/react-query@^5  # Required for v11

# Step 2: Update React Query v4 → v5 usage
# The main breaking changes in React Query v5:
# → isLoading → isPending (for queries in fetching state)
# → data type changes (no more optional in some contexts)
# → onSuccess/onError/onSettled callbacks removed from useQuery
#   (use mutation callbacks instead, or useEffect)

# v4 pattern (deprecated in v5):
const { data } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  onSuccess: (data) => toast.success(`Hello ${data.name}`), // ← removed in v5
});

# v5 pattern:
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
useEffect(() => {
  if (data) toast.success(`Hello ${data.name}`);
}, [data]);

# Step 3: Update tRPC client setup
# v10:
const client = trpc.createClient({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

# v11 (minimal change — mostly same):
import { createTRPCClient, httpBatchLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

# Step 4: Check for removed features
# → superjson transformer: still works but moved to explicit serializer
# → The formData procedure type (if you used it) has changed API

# Total migration time:
# Small app: 2-4 hours (mostly React Query v5 changes)
# Medium app: 1 day
# Large app with many queries: 2-3 days (React Query v5 migration dominates)

Should You Use tRPC in 2026?

Use tRPC when:
→ TypeScript monorepo with shared types (the core use case)
→ Full-stack Next.js, SvelteKit, Nuxt, or similar
→ Multiple clients consuming the same API (web + mobile + CLI)
→ Your team values end-to-end type safety over flexibility
→ You're building complex query patterns (search, filters, pagination, subscriptions)
→ You want React Query's caching layer managed automatically

Skip tRPC when:
→ You're building a public API (REST or GraphQL makes more sense)
→ Your team is split between TypeScript and non-TS consumers
→ Simple CRUD forms — Server Actions cover this more simply
→ Micro-services where the "full-stack TypeScript" assumption breaks down
→ Existing REST API you're extending (tRPC works alongside REST, but complicates it)

The alternative landscape (2026):
→ Server Actions: better for mutations, progressive enhancement
→ React Query + Axios: more flexible, less "magic", larger ecosystem
→ GraphQL + codegen: better for complex graph-like data, multi-team APIs
→ REST + Zod: simpler, more explicit, easier to audit

The verdict:
tRPC remains an excellent choice for its target use case.
The v11 upgrade is worth it (React Query v5 is better than v4).
But 2026 has more good alternatives than 2022 did.
Evaluate based on your actual needs, not the hype.

tRPC v11 and Next.js App Router: The Integration Story

One of the most significant architectural improvements in tRPC v11 is how it integrates with Next.js App Router and React Server Components. In tRPC v10, every procedure call from the client required a React Query hook in a client component — there was no clean way to call tRPC from a server component, which meant your initial page data had to flow through either a separate fetch call or a direct database query in the RSC layer.

tRPC v11 changes this with server-side callers that work natively in React Server Components. A server component can now call await api.post.getAll() directly, with full TypeScript type safety, without a network round-trip. The procedure runs in the same Node.js process, the context is populated from the server-side session, and the response is used to render the initial HTML. This makes tRPC competitive with plain Server Actions for server-to-database data fetching — and critically, it keeps the type-safety guarantees that make tRPC worth using in the first place.

The tradeoff to understand: RSC tRPC calls do not participate in the React Query client cache. When a server component fetches data via tRPC, that data renders into HTML on the server, and the client has no awareness of it for cache invalidation purposes. This is fine for static or slow-changing data. But for data that needs real-time updates, mutations with optimistic updates, or stale-while-revalidate behavior, you still need the client-side tRPC hooks backed by React Query.

The recommended pattern that has emerged in the community is a clear split: use RSC tRPC callers for initial page data (fast, no client-side JS required, great for SEO), and use client-side useQuery and useMutation hooks for interactive data — mutations, real-time updates, and anything that changes in response to user actions. The two patterns compose well because they share the same router type definitions. Rename a field on the server and TypeScript flags both the RSC call and the client hook as broken simultaneously.


The v10 to v11 Migration in Practice

The tRPC v10 to v11 migration is more mechanical than conceptual — the mental model is the same, but some API surface was cleaned up and renamed. Here is the concrete path through it.

The single most visible change is the router definition syntax. In v10, you'd initialize tRPC and call t.router(). In v11, the convention shifted to createTRPCRouter(), which is a named export from your tRPC initialization file. For most codebases this is a find-and-replace operation across your router files.

The more substantial dependency change is React Query. tRPC v11 requires @tanstack/react-query@^5, which itself has breaking changes from v4. The React Query v5 API is largely compatible, but isLoading was split into isPending (data has never loaded) and isFetching (any in-flight request), and the onSuccess/onError callbacks were removed from useQuery in favor of useEffect. Most codebases need to touch every query that uses those callbacks.

Step by step: (1) Update all @trpc/* packages to v11. (2) Update @tanstack/react-query to v5 and work through its migration guide. (3) Update router definition syntax with a find-replace for t.routercreateTRPCRouter and related renames. (4) Update your Next.js tRPC adapter — if you're on Pages Router, @trpc/next v11 still supports it, but you'll need to update the handler setup. If you want to adopt App Router at the same time, that's a separate migration worth doing independently rather than collapsing into the tRPC upgrade. (5) Run tsc --noEmit and fix any type errors surfaced by stricter v11 inference.

For a small app with 10-20 procedures, this migration takes 2-4 hours. Medium apps with 50+ procedures and widespread React Query v4 callback patterns can take a full day, with most of that time in the React Query migration rather than tRPC itself.


tRPC Error Handling in v11

Error handling in tRPC v11 is more structured than most REST or GraphQL implementations, because the type system extends to error types. When you throw a TRPCError, the client knows statically what error codes your procedure can return — and the error code drives both the HTTP status code and the client-side error handling.

The standard error codes map to HTTP status codes deterministically: NOT_FOUND → 404, UNAUTHORIZED → 401, FORBIDDEN → 403, BAD_REQUEST → 400, INTERNAL_SERVER_ERROR → 500. Throwing the right code matters for clients that use the HTTP status code for cache control or retry logic. Middleware like isAuthed typically throws UNAUTHORIZED when the session is missing and FORBIDDEN when the user exists but lacks permission — the distinction is observable from the client and worth maintaining consistently.

The cause field on TRPCError allows chaining the original error for logging purposes without leaking internal error details to the client. In production, unhandled errors that escape your procedures are caught by tRPC's error formatter, which strips the stack trace before sending to the client. Configure a custom errorFormatter to add structured error logging to your observability pipeline — this is where you send tRPC procedure errors to Sentry, Datadog, or your logging service with the context needed to debug them.

Input validation errors are the most common error type in practice. When Zod rejects the input to a procedure, tRPC automatically converts the Zod error into a BAD_REQUEST with the validation error details as the error cause. On the client, error.data?.zodError contains the field-level validation errors in Zod's format, making it straightforward to map validation errors back to form fields in the UI.

tRPC Performance at Scale

tRPC's request batching is one of its most underrated features. When a page component mounts and triggers three separate useQuery calls simultaneously, tRPC's httpBatchLink combines them into a single HTTP request. The server processes all three procedures and returns all three results in one response. This batching is automatic and requires no configuration — it's simply a property of using httpBatchLink instead of httpLink.

The performance implication is significant: three queries that would cause three round-trips over a slow connection complete in one round-trip. For server-rendered pages where initial data needs to load quickly, this batching directly reduces perceived load time. The tradeoff is that all batched queries must wait for the slowest one before any results are returned. For queries with dramatically different latencies — a fast cached lookup alongside a slow database join — using separate links or disabling batching for the slow query prevents the fast result from being delayed.

Middleware performance deserves attention in production tRPC deployments. Every procedure call runs through the middleware chain before reaching the procedure handler. Authentication middleware that makes a database call on every request (to validate the session token) adds latency to every tRPC call. The standard mitigation is caching the session in the context factory — populate the tRPC context once per request with the user session, then pass it to middleware and procedures without re-fetching. Procedures that need the full user object should receive it from context, not fetch it independently. This pattern — populate once in the context factory, consume in procedures — keeps your database query count predictable and prevents N+1 patterns from emerging in middleware chains as the router grows.

Type checking performance in large tRPC routers is a practical concern for codebases with hundreds of procedures. TypeScript's inference of deeply nested router types can become slow as the router grows. tRPC v11 improved this significantly over v10 through more efficient type instantiation, but very large routers may still benefit from code splitting: instead of one massive appRouter, compose multiple smaller routers with t.mergeRouters(). The type checking work is distributed across smaller type objects, which TypeScript handles more efficiently than a single large deeply-nested type. For most applications under 100 procedures, this optimization is premature; for platforms with 300+ procedures, it can meaningfully reduce editor type-check latency.

Compare tRPC, GraphQL, and REST-related package trends at PkgPulse.

Compare tRPC and GraphQL package health on PkgPulse.

See also: React vs Vue and React vs Svelte, From REST to tRPC: The Type-Safe API Revolution.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.