tRPC JSON API: Type-Safe Procedures, Routers & React Query Integration

Last updated:

tRPC uses JSON as its wire format for type-safe remote procedure calls, eliminating the need for a separate schema file by inferring types directly from your TypeScript router definitions. Each procedure serializes input and output to JSON automatically; a tRPC query carrying a 10-field payload adds less than 50 bytes of envelope overhead compared with a hand-rolled REST endpoint. This guide covers defining routers and procedures, integrating with React Query, custom JSON transformers (superjson), error handling with TRPCError, and streaming responses with httpBatchStreamLink.

tRPC Router and Procedure JSON Structure

tRPC procedures serialize input and output to JSON automatically via initTRPC, publicProcedure, and the appRouter type export. The router definition is pure TypeScript — no code generation, no schema file. The inferred AppRouter type is the only contract between client and server.

// server/trpc.ts — initialize tRPC
import { initTRPC } from '@trpc/server'
import type { Context } from './context'

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

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

// server/router.ts — define procedures and compose router
import { z } from 'zod'
import { router, publicProcedure } from './trpc'

// Sub-router: all user-related procedures
const userRouter = router({
  // Query: input is a JSON object { id: number }
  getById: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      // Return value is serialized to JSON automatically
      return { id: input.id, name: 'Alice', email: 'alice@example.com' }
    }),

  // Mutation: input is a JSON object { name, email }
  create: publicProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      // Perform DB write, return new record
      return { id: 42, ...input, createdAt: new Date().toISOString() }
    }),
})

// Root router — compose sub-routers
export const appRouter = router({
  users: userRouter,
})

// Export the type — clients import this for end-to-end type safety
export type AppRouter = typeof appRouter

The AppRouter type is the only artifact shared between the server and client packages. On the wire, a query to users.getById with { id: 1 } serializes to a GET request with URL parameter input={"0":{"json":{"id":1}}}. The response envelope is [{"result":{"data":{"json":{"id":1,"name":"Alice","email":"alice@example.com"}}}}]. The numeric keys are batch indices — even a single call is wrapped in an array because httpBatchLink always batches.

Zod Input Validation with JSON Payloads

tRPC uses Zod .input() to validate incoming JSON before the procedure handler runs. Failed validation throws a BAD_REQUEST error with Zod issue details in the JSON response. Zod's .parse vs .safeParse distinction does not apply inside a tRPC procedure — tRPC calls parse internally and converts any ZodError into a structured TRPCError.

import { z } from 'zod'
import { router, publicProcedure } from './trpc'

const itemRouter = router({
  // Basic object validation — unknown keys are stripped by default
  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        price: z.number().positive(),
        tags: z.array(z.string()).optional().default([]),
      })
    )
    .mutation(async ({ input }) => {
      // input is fully typed: { name: string; price: number; tags: string[] }
      return { id: 1, ...input }
    }),

  // Coerce string query params to numbers
  // (useful when input comes from URL query strings)
  search: publicProcedure
    .input(
      z.object({
        page: z.coerce.number().int().min(1).default(1),
        limit: z.coerce.number().int().min(1).max(100).default(20),
        q: z.string().optional(),
      })
    )
    .query(async ({ input }) => {
      // input.page and input.limit are numbers even if sent as strings
      return { items: [], page: input.page, limit: input.limit }
    }),

  // .strict() rejects unknown keys (default strips them)
  strictCreate: publicProcedure
    .input(z.object({ name: z.string() }).strict())
    .mutation(async ({ input }) => input),

  // Nested object validation
  updateAddress: publicProcedure
    .input(
      z.object({
        userId: z.number(),
        address: z.object({
          street: z.string(),
          city: z.string(),
          zip: z.string().regex(/^\d{5}$/),
        }),
      })
    )
    .mutation(async ({ input }) => {
      // input.address.zip is validated as a 5-digit string
      return { updated: true, userId: input.userId }
    }),
})

// Client-side: validation errors surface in error.data.zodError
// {
//   "fieldErrors": { "price": ["Number must be positive"] },
//   "formErrors": []
// }

Zod strips unknown keys from input JSON by default — this is safe behavior that prevents unexpected fields from reaching your procedure handler. Use .strict() to reject requests with extra keys instead of silently dropping them, which is useful for APIs where you want to surface client bugs early. Zod's z.coerce variants are particularly helpful for tRPC queries because GET request inputs are URL-encoded strings; coercion converts "10" to 10 before validation. See the JSON data validation guide for deeper Zod patterns.

React Query Integration and JSON Caching

tRPC's React Query integration wraps every procedure in a typed useQuery or useMutation hook. Query results are cached in React Query's in-memory store using the procedure path and input JSON as the cache key — ['users','getById',{"id":1}]. Cache behavior is controlled with staleTime, gcTime, and manual invalidation.

// client/trpc.ts — set up tRPC React client
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/router'

export const trpc = createTRPCReact<AppRouter>()

// component/UserProfile.tsx
import { trpc } from '../client/trpc'

function UserProfile({ userId }: { userId: number }) {
  // useQuery: fetches and caches the JSON response
  const { data, isLoading, error } = trpc.users.getById.useQuery(
    { id: userId },
    {
      staleTime: 60_000,   // treat cached JSON as fresh for 60 s
      gcTime: 5 * 60_000,  // keep in cache for 5 min after unmount
      refetchOnWindowFocus: false,
    }
  )

  if (isLoading) return <p>Loading…</p>
  if (error) return <p>Error: {error.message}</p>

  return <div>{data?.name}</div>
}

// component/CreateUser.tsx — mutation with cache invalidation
import { trpc } from '../client/trpc'

function CreateUser() {
  const utils = trpc.useUtils()

  const createMutation = trpc.users.create.useMutation({
    onSuccess: () => {
      // Invalidate all cached user queries after a successful create
      utils.users.getById.invalidate()
    },
  })

  // Optimistic update: update cache before server responds
  const optimisticCreate = trpc.users.create.useMutation({
    onMutate: async (newUser) => {
      await utils.users.getById.cancel()
      // setQueryData for specific input
      utils.users.getById.setData({ id: 99 }, { id: 99, ...newUser })
    },
    onError: (_err, _vars, context) => {
      // Roll back on error — context holds the previous value
    },
    onSettled: () => {
      utils.users.getById.invalidate()
    },
  })

  return (
    <button onClick={() => createMutation.mutate({ name: 'Bob', email: 'bob@example.com' })}>
      Create
    </button>
  )
}

The cache key includes the serialized input JSON, so trpc.users.getById.useQuery({ id: 1 }) and trpc.users.getById.useQuery({ id: 2 }) are separate cache entries. utils.users.getById.invalidate() without arguments invalidates all entries for the procedure regardless of input; pass a specific input to invalidate only one entry. Use utils.invalidate() (no procedure) to invalidate the entire tRPC cache — useful after a logout or a global state reset. See the React Query JSON fetching guide for advanced caching patterns.

superjson Transformer: Extending JSON Types

Standard JSON cannot represent Date, Map, Set, BigInt, or undefined — they are silently lost or converted to strings. tRPC's transformer API lets you plug in superjson to preserve these types across the wire. superjson serializes values to a { json, meta } envelope where meta carries type annotations for deserialization.

// Install: npm install superjson

// server/trpc.ts — configure superjson transformer
import { initTRPC } from '@trpc/server'
import superjson from 'superjson'
import type { Context } from './context'

const t = initTRPC.context<Context>().create({
  transformer: superjson,
})

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

// client/trpc.ts — same transformer on the client
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchStreamLink } from '@trpc/client'
import superjson from 'superjson'
import type { AppRouter } from '../server/router'

export const trpc = createTRPCReact<AppRouter>()

export const trpcClient = trpc.createClient({
  links: [
    httpBatchStreamLink({
      url: '/api/trpc',
      transformer: superjson,
    }),
  ],
})

// server/router.ts — return Date, Map, Set without manual conversion
import { router, publicProcedure } from './trpc'

const eventRouter = router({
  getEvent: publicProcedure.query(async () => {
    return {
      id: 1,
      // Date is preserved end-to-end — no .toISOString() needed
      createdAt: new Date('2026-05-19T00:00:00Z'),
      // Map and Set are preserved too
      tags: new Set(['featured', 'sale']),
      metadata: new Map([['source', 'web'], ['version', '2']]),
      // BigInt is preserved
      viewCount: BigInt(9_007_199_254_740_993),
    }
  }),
})

// Wire format with superjson transformer:
// {
//   "json": {
//     "id": 1,
//     "createdAt": "2026-05-19T00:00:00.000Z",
//     "tags": ["featured", "sale"],
//     "metadata": [["source","web"],["version","2"]],
//     "viewCount": "9007199254740993"
//   },
//   "meta": {
//     "values": {
//       "createdAt": [["Date"]],
//       "tags": [["set"]],
//       "metadata": [["map"]],
//       "viewCount": [["bigint"]]
//     }
//   }
// }

superjson adds approximately 15% serialization overhead compared to JSON.parse / JSON.stringify for simple objects without special types. For objects that contain only JSON-native types (strings, numbers, booleans, null, plain arrays and objects), the extra overhead is unjustified — use the default JSON transformer. Enable superjson only when your procedures genuinely return or accept Date, Map, Set, BigInt, or undefined. The transformer must be configured identically on both server and client; a mismatch causes silent deserialization failures.

Error Handling with TRPCError and JSON Responses

tRPC maps its error codes 1-to-1 to HTTP status codes. Throw a TRPCError inside any procedure to return a structured JSON error response. Customize the error shape globally with errorFormatter to add extra fields to the error JSON.

import { TRPCError } from '@trpc/server'
import { initTRPC } from '@trpc/server'
import { ZodError } from 'zod'

// Custom error formatter — runs on every error before JSON serialization
const t = initTRPC.create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // Attach parsed Zod errors for client-side field validation
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    }
  },
})

// Throwing TRPCError in a procedure
const postRouter = t.router({
  getById: t.procedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      const post = await db.post.findUnique({ where: { id: input.id } })

      if (!post) {
        // NOT_FOUND → HTTP 404
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `Post ${input.id} does not exist`,
        })
      }

      return post
    }),

  create: t.procedure
    .input(z.object({ title: z.string(), authorId: z.number() }))
    .mutation(async ({ input, ctx }) => {
      if (!ctx.user) {
        // UNAUTHORIZED → HTTP 401
        throw new TRPCError({ code: 'UNAUTHORIZED' })
      }

      if (ctx.user.role !== 'admin') {
        // FORBIDDEN → HTTP 403
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Only admins can create posts',
        })
      }

      // INTERNAL_SERVER_ERROR → HTTP 500 (use for unexpected failures)
      try {
        return await db.post.create({ data: input })
      } catch (err) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Failed to create post',
          cause: err,
        })
      }
    }),
})

// Client-side error handling
function PostView({ id }: { id: number }) {
  const { data, error } = trpc.posts.getById.useQuery({ id })

  if (error) {
    // error.data.code: 'NOT_FOUND' | 'UNAUTHORIZED' | 'BAD_REQUEST' | ...
    if (error.data?.code === 'NOT_FOUND') return <p>Post not found</p>
    if (error.data?.code === 'UNAUTHORIZED') return <p>Please log in</p>

    // Zod validation errors from errorFormatter
    const fieldErrors = error.data?.zodError?.fieldErrors
    // { title: ['String must contain at least 1 character'] }
  }
}

The complete TRPCError code-to-HTTP-status mapping: BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, METHOD_NOT_SUPPORTED 405, TIMEOUT 408, CONFLICT 409, PRECONDITION_FAILED 412, PAYLOAD_TOO_LARGE 413, UNPROCESSABLE_CONTENT 422, TOO_MANY_REQUESTS 429, CLIENT_CLOSED_REQUEST 499, INTERNAL_SERVER_ERROR 500, NOT_IMPLEMENTED 501. Use PARSE_ERROR for malformed JSON input and BAD_GATEWAY / SERVICE_UNAVAILABLE for upstream failures. See the JSON API error handling guide for broader error design patterns.

httpBatchStreamLink and Streaming JSON

httpBatchStreamLink batches concurrent tRPC calls into a single HTTP request using a 10 ms deduplication window, then streams each result back via chunked transfer encoding as soon as it resolves. This delivers fast procedure results immediately without waiting for slow ones in the same batch.

// client/trpc.ts — use httpBatchStreamLink for streaming results
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchStreamLink, loggerLink } from '@trpc/client'
import type { AppRouter } from '../server/router'

export const trpc = createTRPCReact<AppRouter>()

export function createTrpcClient(getToken: () => string | null) {
  return trpc.createClient({
    links: [
      loggerLink({ enabled: () => process.env.NODE_ENV === 'development' }),
      httpBatchStreamLink({
        url: '/api/trpc',
        // maxURLLength caps how many calls fit in one GET batch
        maxURLLength: 2083,
        // Headers function runs per-batch, not per-procedure
        headers: () => {
          const token = getToken()
          return token ? { Authorization: 'Bearer ' + token } : {}
        },
        // AbortSignal propagation — cancel the batch if the component unmounts
        AbortController: typeof window !== 'undefined' ? AbortController : null,
      }),
    ],
  })
}

// server/router.ts — async generator for server-sent JSON chunks
import { router, publicProcedure } from './trpc'

const streamRouter = router({
  // Subscription via async generator — each yielded value is a JSON chunk
  onNewMessage: publicProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(async function* ({ input, signal }) {
      // Yield JSON chunks until the client disconnects
      while (!signal?.aborted) {
        const messages = await waitForNewMessages(input.roomId)
        for (const msg of messages) {
          yield msg  // Each msg is serialized to JSON and streamed
        }
      }
    }),
})

// Batching behavior:
// If three components call useQuery simultaneously within 10 ms:
//   trpc.users.getById.useQuery({ id: 1 })  — call A
//   trpc.posts.list.useQuery({})              — call B
//   trpc.settings.get.useQuery({})           — call C
//
// They are batched into one POST /api/trpc?batch=1:
// Body: { "0": { "json": { "id": 1 } }, "1": { "json": {} }, "2": { "json": {} } }
//
// With httpBatchStreamLink, the server streams:
// chunk 1: [{"id":1,"result":{"data":{"json":{...}}}}]   ← call B resolves first
// chunk 2: [{"id":0,"result":{"data":{"json":{...}}}}]   ← call A resolves
// chunk 3: [{"id":2,"result":{"data":{"json":{...}}}}]   ← call C resolves last

The 10 ms batching window is not configurable in the public API — it is an internal deduplication interval. Calls that arrive more than 10 ms apart after the first call in a batch are sent in the next batch. To disable batching entirely (for debugging or for procedures that must not share a request), use httpLink instead of httpBatchStreamLink. For subscriptions backed by WebSockets rather than HTTP streaming, use wsLink with a tRPC WebSocket server — the JSON envelope format is the same, but the transport changes from HTTP chunked to WebSocket frames.

Context, Middleware, and JSON Header Injection

createTRPCContext builds per-request metadata (user, database client, request headers) that every procedure can read via ctx. Middleware adds cross-cutting logic — authentication, logging, rate limiting — by reading and extending the context. Custom fetch links let you inject JSON headers (Authorization, Content-Type, X-Request-Id) on every outgoing request.

// server/context.ts — build per-request context
import type { CreateNextContextOptions } from '@trpc/server/adapters/next'
import { verifyJwt } from './auth'
import { db } from './db'

export async function createTRPCContext({ req }: CreateNextContextOptions) {
  // Parse Authorization header — JSON token from client
  const token = req.headers.authorization?.split(' ')[1] ?? null
  const user = token ? await verifyJwt(token) : null

  return {
    db,
    user,
    requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
  }
}

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

// server/trpc.ts — define middleware and procedure variants
import { initTRPC, TRPCError } from '@trpc/server'
import type { Context } from './context'

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

// Logging middleware — runs before every procedure
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now()
  const result = await next()
  const ms = Date.now() - start
  console.log(`[${type}] ${path} — ${ms}ms — ${result.ok ? 'OK' : 'ERR'}`)
  return result
})

// Auth middleware — throws UNAUTHORIZED if no user in context
const authMiddleware = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  // Pass a narrowed context downstream: ctx.user is now non-null
  return next({ ctx: { ...ctx, user: ctx.user } })
})

// Role middleware — factory for role-based access
const requireRole = (role: string) =>
  t.middleware(({ ctx, next }) => {
    if (ctx.user?.role !== role) {
      throw new TRPCError({ code: 'FORBIDDEN' })
    }
    return next()
  })

export const publicProcedure = t.procedure.use(loggerMiddleware)
export const protectedProcedure = t.procedure
  .use(loggerMiddleware)
  .use(authMiddleware)
export const adminProcedure = t.procedure
  .use(loggerMiddleware)
  .use(authMiddleware)
  .use(requireRole('admin'))

// client/trpc.ts — inject JSON headers in the fetch link
import { httpBatchStreamLink } from '@trpc/client'

httpBatchStreamLink({
  url: '/api/trpc',
  headers: async () => ({
    // Standard JSON content negotiation header
    'Content-Type': 'application/json',
    // Auth token from cookie / localStorage
    'Authorization': 'Bearer ' + (await getAuthToken()),
    // Correlation ID for distributed tracing
    'X-Request-Id': crypto.randomUUID(),
  }),
  // Custom fetch to add global error handling or retries
  fetch: async (url, options) => {
    const res = await fetch(url, { ...options, credentials: 'include' })
    if (res.status === 401) {
      await refreshAuthToken()
    }
    return res
  },
})

Middleware chains execute in declaration order — the first .use() wraps the outermost layer, the last wraps the innermost. Middleware can call next() with a modified context to pass enriched data to the next layer or to the procedure handler. Because middleware is composable, you can build a library of reusable middleware (rate limiting, audit logging, feature flags) and attach them to individual procedures or entire sub-routers. For request-level JSON header injection (tracing IDs, versioning), the headers function in httpBatchStreamLink runs once per batch — not once per procedure — so all procedures in a batch share the same headers object.

Key Terms

procedure
The fundamental tRPC unit of work, analogous to a REST endpoint. A procedure has an optional Zod .input() schema, an optional .output() schema, and a resolver function (.query() for reads or .mutation() for writes). The resolver receives a typed input and a ctx (context) object, and returns a JSON-serializable value. Procedures are defined on a router and accessed by their dot-path: appRouter.users.getById. On the wire, each procedure call becomes one entry in the batch JSON envelope.
router
A tRPC construct that groups related procedures and sub-routers under a namespace. Created with t.router({ ... }), a router is a plain object whose keys are procedure names or nested router names. Routers compose hierarchically — the root appRouter may contain userRouter, postRouter, etc. The router object is never sent over the wire; only its inferred TypeScript type (AppRouter) is shared with the client to enable end-to-end type inference without code generation.
context
A per-request object built by createTRPCContext and injected into every procedure handler and middleware as ctx. Typically contains the authenticated user, a database client, and parsed request headers. Context is the primary mechanism for sharing request-scoped state across procedures in the same request. Middleware can extend the context by calling next({ ctx: { ...ctx, extraField } }), making new fields available to downstream procedures in a type-safe way.
transformer
A pair of serialize / deserialize functions plugged into tRPC to extend the JSON wire format. The default transformer is a pass-through — values are serialized with JSON.stringify and deserialized with JSON.parse. The superjson transformer extends this to preserve Date, Map, Set, BigInt, RegExp, and undefined by attaching a meta annotation object alongside the JSON value. The transformer must be configured identically on both the tRPC server initialization and the client link.
middleware
A function created with t.middleware() that wraps procedure execution, receiving the current context and a next function to call the next layer. Middleware can read and modify the context, short-circuit with a thrown TRPCError, or add post-processing logic after calling next(). Multiple middleware are composed with chained .use() calls on a procedure or base procedure. Common uses: authentication checks, rate limiting, structured logging, and injecting database transactions into the context.
subscription
A tRPC procedure type that returns an async iterable (async generator) of JSON values, enabling server-to-client streaming. Subscriptions are defined with .subscription() instead of .query() or .mutation(), and the resolver is an async generator function that yields values. Each yielded value is serialized to JSON and sent as a streaming chunk. On the client, subscriptions are consumed with trpc.myProcedure.useSubscription() or via the vanilla client's .subscribe() method. Transport can be HTTP streaming (httpBatchStreamLink) or WebSockets (wsLink).

FAQ

What JSON format does tRPC use for requests and responses?

tRPC sends queries as HTTP GET requests with a JSON-encoded input query parameter and mutations as HTTP POST requests with a JSON body. The request envelope is {"0":{"json":{"yourField":"value"}}} where the numeric key is the batch index. Responses follow the same envelope: [{"result":{"data":{"json":{"yourField":"value"}}}}]. The nested json key is a transformer slot — when using the default JSON transformer it holds the raw value; when using superjson it holds a superjson-encoded value with a meta sibling for type annotations. This envelope adds roughly 40–50 bytes overhead per request compared to a hand-rolled REST endpoint.

How do I handle tRPC errors on the client side?

tRPC errors surface in React Query's error field as a TRPCClientError instance. Access the error code with error.data?.code (a string like "NOT_FOUND" or "UNAUTHORIZED") and the HTTP status with error.data?.httpStatus. For Zod validation errors, the parsed issues are in error.data?.zodError?.fieldErrors — a record of field name to error message array — when you have configured a custom errorFormatter on the server. Use error.message for a human-readable string. For mutations, destructure { mutate, error } from trpc.myProcedure.useMutation(). Always check error.data?.code before displaying messages; map codes to user-friendly text in your UI layer rather than exposing raw server error strings.

Can I use tRPC without React Query?

Yes. tRPC provides a vanilla client via createTRPCClient that works in any JavaScript environment — Node.js scripts, CLI tools, server-to-server calls, or test suites. Call procedures with client.myRouter.myProcedure.query(input) or .mutate(input). The vanilla client returns a plain Promise resolving to the output type, with no caching, loading states, or invalidation. For server-side rendering in Next.js without React Query hooks, use createCaller(ctx) on the server to call procedures directly without HTTP overhead — this is the recommended pattern for RSC and getServerSideProps. The React Query integration is an optional layer built on top of the vanilla client.

What is the difference between query and mutation in tRPC JSON terms?

Queries map to HTTP GET requests and are used for read operations. The JSON input is URL-encoded as a query parameter: ?batch=1&input=%7B%220%22%3A%7B%22json%22%3A...%7D%7D. Mutations map to HTTP POST requests with the JSON input in the request body: {"0":{"json":{"field":"value"}}}. The distinction affects caching: GET requests can be cached by HTTP infrastructure (CDN, browser cache), while POST requests are not cached by default. In React Query terms, queries use useQuery and populate the cache; mutations use useMutation and typically trigger cache invalidation via utils.invalidate()after success. If a query's input JSON is too large for a URL, switch it to a mutation to use the request body instead.

How do I add authentication to tRPC procedures using JSON tokens?

Pass the JWT in the HTTP Authorization header from the client link: in httpBatchStreamLink, set headers: async () => ({ Authorization: "Bearer " + token }). On the server, read the header in createTRPCContext: const token = req.headers.authorization?.split(" ")[1]. Verify the JWT and attach the decoded user to the context object returned from createTRPCContext. Then write a middleware with t.middleware that reads ctx.user and throws new TRPCError({ code: "UNAUTHORIZED" }) if absent. Compose the middleware into a protectedProcedure export and use it instead of publicProcedure for all authenticated routes.

How does httpBatchLink differ from httpBatchStreamLink?

Both links batch multiple concurrent tRPC calls into a single HTTP request within a 10 ms deduplication window. The difference is in how responses are delivered. httpBatchLink waits for all procedures in the batch to complete, then returns all results in one JSON array response — the client receives nothing until the slowest procedure finishes. httpBatchStreamLink uses HTTP streaming (chunked transfer encoding) to return each result as soon as it resolves — the client receives results incrementally rather than waiting. Use httpBatchStreamLink when your batch mixes fast and slow procedures to improve perceived latency; the fast results render immediately while slow ones are still pending. Both links are available in @trpc/client.

Can tRPC procedures return non-JSON types like files or blobs?

tRPC procedures must return JSON-serializable values by default. To return binary data, encode it as a base64 string in the procedure and decode on the client, or use a separate non-tRPC route (e.g., a Next.js Route Handler) for file downloads and return only the file URL from tRPC. With the superjson transformer, you can return Date, Map, Set, and BigInt — but not Blob, Buffer, or ArrayBuffer. For file uploads, use a multipart form POST to a regular HTTP endpoint and return only the file metadata (URL, size, MIME type) from a tRPC mutation. This keeps tRPC in its JSON sweet spot while delegating binary transport to a purpose-built endpoint.

How do I migrate from REST JSON APIs to tRPC?

Migration is incremental. First, add tRPC alongside your existing REST routes — they coexist in the same Next.js app. Second, translate each REST endpoint to a tRPC procedure: GET /users/:id becomes a query, POST /users becomes a mutation. Move Joi/Yup validation to Zod and pass schemas to .input(). Replace fetch("/api/users/1") on the client with trpc.users.getById.useQuery({ id: 1 }). Third, delete the REST route once all clients use the tRPC procedure. The main JSON shape change is the batch envelope wrapper — if you have non-tRPC consumers (mobile apps, third-party integrations), keep a REST or OpenAPI adapter rather than forcing all consumers to use the tRPC client.

Further reading and primary sources