tRPC v11: End-to-End Type Safety Without the Schema Tax

No REST endpoints. No GraphQL schemas. No code generation. tRPC v11 gives you full TypeScript type safety from your server function to your React component — and it works beautifully with the App Router.


Every API layer is a contract. The question is whether TypeScript knows about it. REST doesn’t tell your frontend what the server returns. GraphQL requires a schema, a codegen step, and a build pipeline just to get types. tRPC takes a different path: the types are the contract, and TypeScript enforces them automatically — from the function you write on the server to the component that calls it.

tRPC v11 is the most mature and capable version of that idea yet. It has been rewritten to work natively with React Server Components, the Next.js App Router, and modern React patterns like Suspense and streaming. If you’ve been putting off learning tRPC because it seemed like a niche tool for small projects, 2026 is the year to reconsider.

This post walks through what tRPC v11 actually does, how to set it up, and how to use it correctly — including the patterns that don’t make it into most tutorials.


The Problem tRPC Solves

Consider the standard full-stack TypeScript setup without tRPC. You write a server route that returns a User. On the client you call it with fetch. TypeScript has no idea what you’re getting back — it’s any until you cast it, and your cast is a promise you make to yourself with no enforcement behind it.

// Server: app/api/user/route.ts
export async function GET() {
  const user = await db.user.findFirst()
  return Response.json(user)
}

// Client: components/Profile.tsx
const res  = await fetch('/api/user')
const user = await res.json()
// ^ TypeScript type: any
// You can write user.nonExistentField and TS won't complain
// The server might return null — you'd never know until runtime

GraphQL solves this with a schema and a codegen step. That’s a real solution, but it has real costs: schema files to maintain, a codegen step in your build pipeline, and a resolver layer that duplicates your business logic. For most full-stack TypeScript applications, that’s more infrastructure than the problem warrants.

tRPC’s answer is simpler: skip the schema entirely. Your server functions are the API. TypeScript infers their types. The client uses those inferred types directly — no schema, no codegen, no runtime reflection.

Server function  →  TypeScript infers return type
Return type      →  tRPC exports as router type
Router type      →  Client imports and uses directly
Client call      →  ✓ Fully typed. No build step.

Core Concepts in tRPC v11

Before jumping into setup, it’s worth understanding the three building blocks of any tRPC application.

1. Procedures

A procedure is a server function. It can be a query (reads data, like a GET) or a mutation (writes data, like a POST/PUT/DELETE). Each procedure can validate its input with Zod, apply middleware, and return any TypeScript value.

2. Routers

A router groups related procedures — like a controller in MVC. Routers can be nested, so you can have userRouter, postRouter, and a root appRouter that combines them.

3. The Client

The tRPC client is a proxy that mirrors the shape of your router. When you call trpc.user.getById.query({ id: 1 }), TypeScript knows exactly what that returns — because it’s reading the type of the server function directly, via a type import.

How does the type sharing work? Your server exports a type: export type AppRouter = typeof appRouter. The client imports only that type — not the server code itself. At runtime, calls go through HTTP as normal. At compile time, TypeScript uses the type to validate every call and its return value.


Setting Up tRPC v11 with Next.js App Router

Installation

# Core tRPC packages
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next @trpc/next@next

# Peer dependencies — tRPC v11 requires React Query v5
npm install @tanstack/react-query@^5 zod superjson

Step 1: Create the tRPC Instance

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { cache } from 'react'
import { getServerSession } from 'next-auth/next'
import superjson from 'superjson'

// Context created once per request
export const createTRPCContext = cache(async () => {
  const session = await getServerSession()
  return { session, db }
})

const t = initTRPC
  .context<typeof createTRPCContext>()
  .create({
    // superjson handles Date, Map, Set, undefined across the wire
    transformer: superjson,
  })

// Reusable builder and middleware exports
export const { router, procedure: publicProcedure, middleware } = t

// Protected procedure — throws if no session
const enforceAuth = middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return next({ ctx: { ...ctx, user: ctx.session.user } })
})

export const protectedProcedure = publicProcedure.use(enforceAuth)

Step 2: Define Your Router

// server/routers/user.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'

export const userRouter = router({

  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.db.user.findUniqueOrThrow({
        where: { id: input.id },
        select: { id: true, name: true, email: true }
      })
    }),

  updateProfile: protectedProcedure
    .input(z.object({
      name:  z.string().min(2).max(50),
      email: z.string().email(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.update({
        where: { id: ctx.user.id },
        data:  input,
      })
    }),

})

// server/routers/index.ts — root router
export const appRouter = router({
  user: userRouter,
  post: postRouter,
})

// The only export the client ever needs
export type AppRouter = typeof appRouter

Step 3: Create the API Route Handler

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers'
import { createTRPCContext } from '@/server/trpc'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint:      '/api/trpc',
    req,
    router:        appRouter,
    createContext: createTRPCContext,
    onError: process.env.NODE_ENV === 'development'
      ? ({ error }) => console.error(error)
      : undefined,
  })

export { handler as GET, handler as POST }

Client Setup: Two Patterns for the App Router

tRPC v11 supports two client patterns in the App Router: a React Query-based client for Client Components, and a direct server-caller for Server Components. Understanding which to use where is the key to getting the most out of the architecture.

Pattern A: React Query Client (for Client Components)

// lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query'
import { AppRouter } from '@/server/routers'

export const trpc = createTRPCReact<AppRouter>()
// app/providers.tsx
'use client'

import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchStreamLink } from '@trpc/client'
import superjson from 'superjson'
import { trpc } from '@/lib/trpc/client'

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchStreamLink({
          url:         '/api/trpc',
          transformer: superjson,
        }),
      ],
    })
  )

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  )
}

Pattern B: Direct Server Caller (for Server Components)

In tRPC v11, you can call your procedures directly in React Server Components without going through HTTP at all. This is the preferred pattern for data that can be fetched at render time — no waterfall, no network round-trip.

// lib/trpc/server.ts
import { createCallerFactory } from '@trpc/server'
import { cache } from 'react'
import { appRouter } from '@/server/routers'
import { createTRPCContext } from '@/server/trpc'

const createCaller = createCallerFactory(appRouter)

// cache() ensures the context is created only once per request
export const api = cache(async () => {
  const ctx = await createTRPCContext()
  return createCaller(ctx)
})
// app/users/[id]/page.tsx — Server Component
import { api } from '@/lib/trpc/server'

export default async function UserPage({ params }: { params: { id: string } }) {
  const caller = await api()

  // Direct function call — no HTTP, no fetch, full type safety
  const user = await caller.user.getById({ id: params.id })
  //    ^ TypeScript knows this is: { id: string; name: string; email: string }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

Rule of thumb: Use the server caller (api()) in Server Components for initial page data. Use the React Query client (trpc.*) in Client Components for interactive queries, mutations, optimistic updates, and anything that needs refetching.


Input Validation with Zod

Every tRPC procedure that accepts input should validate it with Zod. This gives you two things simultaneously: runtime safety (bad input is rejected with a clean error) and TypeScript inference (the input parameter inside your procedure is fully typed).

import { z } from 'zod'

const createPostSchema = z.object({
  title:     z.string().min(5).max(120),
  content:   z.string().min(20),
  tags:      z.array(z.string()).max(5).default([]),
  published: z.boolean().default(false),
})

createPost: protectedProcedure
  .input(createPostSchema)
  .mutation(async ({ ctx, input }) => {
    // input is: { title: string; content: string; tags: string[]; published: boolean }
    // TypeScript inferred from the Zod schema — no manual type declaration needed
    return ctx.db.post.create({
      data: { ...input, authorId: ctx.user.id }
    })
  })

When a client passes invalid input, tRPC automatically returns a BAD_REQUEST error with Zod’s validation message. No extra error handling required inside the procedure itself.


Mutations in Client Components

// components/EditProfileForm.tsx
'use client'

import { trpc } from '@/lib/trpc/client'
import { useQueryClient } from '@tanstack/react-query'
import { getQueryKey } from '@trpc/react-query'

export function EditProfileForm({ userId }: { userId: string }) {
  const queryClient = useQueryClient()

  const { data: user } = trpc.user.getById.useQuery({ id: userId })

  const updateProfile = trpc.user.updateProfile.useMutation({
    onSuccess: () => {
      // Invalidate the user query — it will refetch automatically
      queryClient.invalidateQueries({
        queryKey: getQueryKey(trpc.user.getById, { id: userId })
      })
    },
  })

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const form = new FormData(e.currentTarget)
    await updateProfile.mutateAsync({
      name:  form.get('name') as string,
      email: form.get('email') as string,
    })
    // TypeScript catches wrong field names at compile time
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name"  defaultValue={user?.name}  />
      <input name="email" defaultValue={user?.email} />
      <button disabled={updateProfile.isPending}>
        {updateProfile.isPending ? 'Saving…' : 'Save'}
      </button>
    </form>
  )
}

Error Handling

tRPC uses a set of typed error codes that map to HTTP status codes. You throw them with TRPCError on the server and catch them on the client with full type information.

// Server: throw typed errors
import { TRPCError } from '@trpc/server'

deletePost: 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)
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Not your post' })
    return ctx.db.post.delete({ where: { id: input.id } })
  })

// Client: catch with full type info
import { TRPCClientError } from '@trpc/client'

try {
  await deletePost.mutateAsync({ id: postId })
} catch (err) {
  if (err instanceof TRPCClientError) {
    toast.error(err.message)           // typed: string
    console.log(err.data?.code)        // 'NOT_FOUND' | 'FORBIDDEN' | …
    console.log(err.data?.httpStatus)  // 404 | 403 | …
  }
}

What’s New in tRPC v11 Specifically

If you’ve used tRPC v10, here’s what v11 brings that’s worth upgrading for.

Native App Router Support

tRPC v11 is designed from the ground up for the Next.js App Router. The createCallerFactory API, React’s cache() integration, and the fetch adapter all work correctly in the Server Components model — including streaming and Suspense.

Streaming with httpBatchStreamLink

The new httpBatchStreamLink (which replaces httpBatchLink as the recommended default) supports streaming responses. When you batch multiple queries, the client renders each result as it streams in rather than waiting for all of them to complete.

import { httpBatchStreamLink } from '@trpc/client'

// This replaces httpBatchLink — streaming by default in v11
trpc.createClient({
  links: [
    httpBatchStreamLink({
      url:         '/api/trpc',
      transformer: superjson,
      // Optional: pass auth headers per request
      headers: async () => ({
        Authorization: `Bearer ${await getToken()}`,
      }),
    }),
  ],
})

useSuspenseQuery (React 18+)

tRPC v11 supports useSuspenseQuery and useSuspenseInfiniteQuery — the React Query v5 hooks that integrate with React Suspense. This lets you co-locate data loading with the component that uses it, relying on Suspense boundaries above for loading states.

'use client'

import { trpc } from '@/lib/trpc/client'

function UserCard({ id }: { id: string }) {
  // data is always defined here — Suspense handles the loading state above
  const [user] = trpc.user.getById.useSuspenseQuery({ id })

  return <div>{user.name}</div>  // no user?.name needed
}

// In a parent component:
import { Suspense } from 'react'

<Suspense fallback={<Skeleton />}>
  <UserCard id={userId} />
</Suspense>

Typed Middlewares with Chaining

v11 improves the middleware API so context transformations are fully type-safe as they chain. Each middleware can narrow the context type, and the final procedure always sees the correctly accumulated type.

// Middleware chain — each step narrows the context type
const withUser = middleware(async ({ ctx, next }) => {
  const user = await getUser(ctx.session)
  return next({ ctx: { ...ctx, user } })
  //                              ^ ctx.user is now typed as User, not undefined
})

const withOrg = middleware(async ({ ctx, next }) => {
  const org = await getOrg(ctx.user.orgId)
  //                            ^ ctx.user is available here, correctly typed
  return next({ ctx: { ...ctx, org } })
})

export const orgProcedure = publicProcedure
  .use(withUser)
  .use(withOrg)
  // Procedures using orgProcedure have ctx.user AND ctx.org — both typed

tRPC vs REST vs GraphQL: A Direct Comparison

CriteriontRPC v11REST + OpenAPIGraphQL
End-to-end typesAutomatic (inferred)Manual or codegenCodegen required
Schema definitionNone neededOpenAPI specGraphQL SDL required
Build step for typesNoneOptionalRequired
Input validationZod built-inManual or libraryResolvers + library
External API consumersNot designed for thisExcellentExcellent
Learning curveLow (TypeScript only)LowHigh
App Router supportNative (v11)NativeVia libraries
Best forFull-stack TS monoreposPublic APIs, microservicesComplex graph data

Where tRPC doesn’t fit: tRPC is designed for TypeScript monorepos where the client and server share a codebase. If you need a public API consumed by third-party clients, a mobile app written in another language, or a microservices architecture where teams maintain services independently — REST or GraphQL will serve you better.


A Complete File Structure at a Glance

your-app/
├── app/
│   ├── api/
│   │   └── trpc/
│   │       └── [trpc]/
│   │           └── route.ts        # catch-all tRPC handler
│   ├── providers.tsx               # QueryClient + tRPC provider
│   └── users/
│       └── [id]/
│           └── page.tsx            # Server Component using api()
├── server/
│   ├── trpc.ts                     # initTRPC, context, middleware
│   └── routers/
│       ├── index.ts                # appRouter + AppRouter type export
│       ├── user.ts                 # userRouter
│       └── post.ts                 # postRouter
└── lib/
    └── trpc/
        ├── client.ts               # createTRPCReact<AppRouter>
        └── server.ts               # createCallerFactory + api()

Final Thoughts

tRPC v11 is the best version of a genuinely good idea. The core proposition — that the type system is the contract and TypeScript is the enforcement mechanism — is elegant in a way that grows more valuable as your application scales.

Every time you rename a field on a server procedure and TypeScript immediately surfaces every call site that needs updating, you understand why the abstraction is worth it. Every time you call a mutation and the autocomplete shows you exactly what the input expects, you understand what “end-to-end type safety” actually means in practice.

The v11 additions — native App Router support, the server caller pattern, streaming via httpBatchStreamLink, Suspense integration, and improved middleware typing — turn tRPC into a tool that covers the full Next.js App Router architecture without compromises.

If you’re building a full-stack TypeScript application in 2026 and your API is internal to that application, tRPC v11 is almost certainly the right choice.

Set it up once. Let TypeScript do the rest.

Leave a Reply

Your email address will not be published. Required fields are marked *