JSON in tRPC: Superjson, Input Validation, Type Inference & Batch Requests
Last updated:
tRPC transmits all procedure inputs and outputs as JSON over HTTP — trpc.user.byId.query({id: "123"}) serializes {"id": "123"} as a JSON query parameter and deserializes the JSON response into a fully typed TypeScript value, with no manual JSON.stringify or JSON.parse at any call site. By default, tRPC uses superjson as a data transformer, extending standard JSON to transport Date, Map, Set, BigInt, and undefined — types JSON cannot natively represent. Zod validates procedure inputs before they reach handler code: input: z.object({id: z.string().uuid()}) returns a JSON {"code": "BAD_REQUEST", "message": "..."} error automatically for invalid input. TypeScript infers the exact output type of every procedure from the return value — inferAsyncReturnType extracts the type statically. This guide covers defining tRPC routers and procedures, Zod input schemas, superjson for extended types, error shapes, batch requests, and integrating tRPC with Next.js App Router.
How tRPC Serializes Data as JSON Over HTTP
Every tRPC procedure call becomes an HTTP request with JSON as the wire format. Query procedures send their input as a URL-encoded JSON string in the ?input= query parameter; mutation procedures POST a JSON body. The server returns a JSON object with a result.data.json field containing the procedure's return value. You never write JSON.stringify on the client or JSON.parse on the server — tRPC handles both ends of the serialization boundary transparently.
// ── tRPC v11 wire format over HTTP ───────────────────────────
// QUERY: GET /api/trpc/user.byId?input=%7B%22json%22%3A%7B%22id%22%3A%22abc-123%22%7D%7D
// Decoded input: {"json":{"id":"abc-123"}}
// Server response (HTTP 200):
// {
// "result": {
// "data": {
// "json": {
// "id": "abc-123",
// "name": "Alice",
// "createdAt": "2026-05-28T10:00:00.000Z"
// },
// "meta": {
// "values": { "createdAt": ["Date"] }
// }
// }
// }
// }
// The "meta" field is added by superjson — it tells the client
// that createdAt should be re-instantiated as a Date object.
// MUTATION: POST /api/trpc/user.create
// Body: {"json":{"name":"Bob","email":"bob@example.com"}}
// Content-Type: application/json
// ERROR response (HTTP 400):
// {
// "error": {
// "json": {
// "message": "id: Invalid uuid",
// "code": -32600,
// "data": {
// "code": "BAD_REQUEST",
// "httpStatus": 400,
// "path": "user.byId",
// "zodError": {
// "fieldErrors": { "id": ["Invalid uuid"] },
// "formErrors": []
// }
// }
// }
// }
// }
// ── Client call — no manual JSON handling needed ─────────────
import { trpc } from '@/utils/trpc'
function UserCard({ id }: { id: string }) {
const { data, error } = trpc.user.byId.useQuery({ id })
// data is typed as { id: string; name: string; createdAt: Date }
// createdAt is already a Date — superjson re-instantiated it
if (error) console.log(error.data?.code) // "BAD_REQUEST" | "NOT_FOUND" | ...
return <div>{data?.name}</div>
}The JSON encoding wraps values in a {"{ json: value }"} envelope to accommodate the superjson transformer's metadata. When no transformer is configured, the envelope is still present but the meta field is omitted. The code field in error responses uses JSON-RPC 2.0 numeric codes internally, while data.code uses tRPC's human-readable string codes — prefer data.code for programmatic error handling. tRPC v11's Fetch API adapter makes procedure handlers first-class Web Standard Request/Response handlers, compatible with any runtime that supports the Fetch API.
Defining Query and Mutation Procedures with JSON Input
tRPC procedures are the unit of API definition — each procedure specifies its JSON input schema, whether it's a query (read) or mutation (write), and its handler function. Routers group related procedures under a namespace. The complete router type is exported from the server and imported as a pure TypeScript type on the client — no runtime bundle crossing the server/client boundary.
// server/trpc.ts — initialize tRPC with context and transformer
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import type { Context } from './context'
const t = initTRPC.context<Context>().create({
transformer: superjson, // extends JSON for Date, Map, Set, BigInt
})
export const router = t.router
export const publicProcedure = t.procedure
export const middleware = t.middleware
// ── Protected procedure — requires auth ──────────────────────
const isAuthed = middleware(({ ctx, next }) => {
if (!ctx.session?.userId) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, userId: ctx.session.userId } })
})
export const protectedProcedure = t.procedure.use(isAuthed)
// ── server/routers/user.ts — query and mutation procedures ───
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'
export const userRouter = router({
// QUERY — GET /api/trpc/user.byId?input=...
// Input: JSON object { id: string }
// Output: User object or null
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
// input.id is typed as string — Zod validated it
return ctx.db.user.findUnique({ where: { id: input.id } })
}),
// QUERY with pagination — JSON input with defaults
list: publicProcedure
.input(z.object({
cursor: z.string().optional(), // for cursor pagination
limit: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const items = await ctx.db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
where: input.search
? { name: { contains: input.search, mode: 'insensitive' } }
: undefined,
})
const nextCursor = items.length > input.limit
? items.pop()!.id // remove extra item
: undefined
return { items, nextCursor }
}),
// MUTATION — POST /api/trpc/user.create
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']).default('USER'),
}))
.mutation(async ({ input, ctx }) => {
// input is typed as { name: string; email: string; role: 'USER' | 'ADMIN' }
return ctx.db.user.create({ data: input })
}),
// MUTATION — DELETE with a single ID
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.user.delete({ where: { id: input.id } })
return { success: true } // return a JSON value confirming deletion
}),
})
// ── server/router.ts — merge sub-routers ─────────────────────
import { router } from './trpc'
import { userRouter } from './routers/user'
import { postRouter } from './routers/post'
export const appRouter = router({
user: userRouter,
post: postRouter,
})
export type AppRouter = typeof appRouter // exported type — no JS bundleRouters nest by passing sub-routers as values in the root router object — { user: userRouter, post: postRouter }. The dot-notation path in HTTP requests (user.byId, post.list) mirrors this nesting. Procedures without .input() accept no input — the query parameter or request body is ignored. Use publicProcedure for unauthenticated endpoints and compose middleware-wrapped procedures for authenticated or role-restricted routes.
Zod Input Validation and JSON Error Shapes
Passing a Zod schema to .input() makes tRPC call schema.parse() on the incoming JSON before the handler runs. Invalid input never reaches your database or business logic. tRPC translates Zod validation failures into structured JSON errors with code: "BAD_REQUEST" and a zodError field containing flattened field errors — the same structure as ZodError.flatten().
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
// ── Input validation with Zod ────────────────────────────────
const CreatePostInput = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string().min(1).max(50)).max(10, 'Maximum 10 tags').default([]),
status: z.enum(['draft', 'published']).default('draft'),
slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(),
})
// ── JSON error shape for failed Zod validation ───────────────
// POST /api/trpc/post.create with body: {"json":{"title":"","content":"hi"}}
// Response (HTTP 400):
// {
// "error": {
// "json": {
// "message": "title: Title is required
content: Content must be at least 10 characters",
// "code": -32600,
// "data": {
// "code": "BAD_REQUEST",
// "httpStatus": 400,
// "path": "post.create",
// "zodError": {
// "fieldErrors": {
// "title": ["Title is required"],
// "content": ["Content must be at least 10 characters"]
// },
// "formErrors": []
// }
// }
// }
// }
// }
export const postRouter = router({
create: protectedProcedure
.input(CreatePostInput)
.mutation(async ({ input, ctx }) => {
// Runs only if all Zod validations pass
// input is typed as z.infer<typeof CreatePostInput>
return ctx.db.post.create({
data: { ...input, authorId: ctx.userId },
})
}),
// ── Throwing TRPCError for business logic errors ─────────────
publish: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } })
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND', // → HTTP 404
message: `Post ${input.id} not found`,
})
}
if (post.authorId !== ctx.userId) {
throw new TRPCError({
code: 'FORBIDDEN', // → HTTP 403
message: 'You can only publish your own posts',
})
}
if (post.status === 'published') {
throw new TRPCError({
code: 'CONFLICT', // → HTTP 409
message: 'Post is already published',
})
}
return ctx.db.post.update({
where: { id: input.id },
data: { status: 'published', publishedAt: new Date() },
})
}),
})
// ── Client-side error handling ───────────────────────────────
import { TRPCClientError } from '@trpc/client'
try {
await trpc.post.publish.mutate({ id: 'some-id' })
} catch (err) {
if (err instanceof TRPCClientError) {
console.log(err.data?.code) // 'NOT_FOUND' | 'FORBIDDEN' | 'CONFLICT' | ...
console.log(err.data?.zodError) // only present on BAD_REQUEST
console.log(err.message) // human-readable message
}
}TRPCError maps string code values to HTTP status codes — BAD_REQUEST → 400, UNAUTHORIZED → 401, NOT_FOUND → 404, INTERNAL_SERVER_ERROR → 500. On the client, TRPCClientError.data.code gives the string code for programmatic branching without parsing message strings. The zodError field in BAD_REQUEST responses contains the same structure as ZodError.flatten() — use zodError.fieldErrors to display per-field error messages in forms.
Superjson: Extending JSON for Date, Map, Set, and undefined
Standard JSON loses type information for JavaScript's rich value types — a Date becomes a string, a Map becomes {}, a Set becomes [], and undefined disappears entirely. superjson preserves these types by sending a parallel metadata structure alongside the JSON-safe values, letting the client reconstruct the original types without any application code.
import superjson from 'superjson'
// ── What superjson serializes ────────────────────────────────
const value = {
createdAt: new Date('2026-05-28T10:00:00.000Z'),
tags: new Set(['typescript', 'trpc', 'json']),
metadata: new Map([['key1', 'value1'], ['key2', 'value2']]),
count: BigInt(9007199254740993), // > Number.MAX_SAFE_INTEGER
optValue: undefined, // JSON.stringify drops this
}
const serialized = superjson.stringify(value)
// Produces:
// {
// "json": {
// "createdAt": "2026-05-28T10:00:00.000Z",
// "tags": ["typescript", "trpc", "json"],
// "metadata": [["key1", "value1"], ["key2", "value2"]],
// "count": "9007199254740993",
// "optValue": null
// },
// "meta": {
// "values": {
// "createdAt": ["Date"],
// "tags": ["Set"],
// "metadata": ["Map"],
// "count": ["bigint"],
// "optValue": ["undefined"]
// }
// }
// }
const deserialized = superjson.parse(serialized)
// deserialized.createdAt instanceof Date → true
// deserialized.tags instanceof Set → true (not an array)
// deserialized.metadata instanceof Map → true (not {})
// typeof deserialized.count === 'bigint' → true
// 'optValue' in deserialized → true (undefined preserved)
// ── Configuring superjson in tRPC ────────────────────────────
// server/trpc.ts
import { initTRPC } from '@trpc/server'
import superjson from 'superjson'
const t = initTRPC.create({ transformer: superjson })
// client/trpc.ts — must match server transformer
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import type { AppRouter } from '../server/router'
export const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson, // same transformer as server
}),
],
})
// ── Procedure returning a Date — type flows through correctly ─
// Server:
const eventRouter = router({
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => ({
id: input.id,
name: 'Launch',
startsAt: new Date('2026-06-01'), // Date on server
attendees: new Set(['alice', 'bob']), // Set on server
})),
})
// Client:
const { data } = trpc.event.get.useQuery({ id: '1' })
// data is typed as:
// { id: string; name: string; startsAt: Date; attendees: Set<string> }
// startsAt is Date (not string) — superjson re-instantiated it
// attendees is Set<string> (not string[]) — superjson re-instantiated it
console.log(data?.startsAt.getFullYear()) // 2026 — works as a DateThe transformer must be configured identically on both the server and client. If the server sends superjson-encoded responses but the client has no transformer, the client receives the raw {"{ json: ..., meta: ... }"} object rather than the deserialized value. When using createServerSideHelpers for SSR in Next.js, pass transformer: superjson there as well — it calls procedures on the server side and must use the same transformer to pre-populate the React Query cache with correctly typed values.
TypeScript Type Inference from JSON Outputs
tRPC's type inference system is its defining feature. The server exports the router type — a pure TypeScript construct with no runtime cost — and the client imports it to get fully typed procedure calls with autocomplete, parameter checking, and return type inference. No code generation, no OpenAPI spec, no manual type maintenance.
// server/router.ts
import { router } from './trpc'
import { userRouter } from './routers/user'
import { postRouter } from './routers/post'
export const appRouter = router({ user: userRouter, post: postRouter })
// Export ONLY the type — zero JS sent to the client
export type AppRouter = typeof appRouter
// ── inferAsyncReturnType — extract procedure output types ────
import type { inferRouterOutputs, inferRouterInputs } from '@trpc/server'
type RouterOutputs = inferRouterOutputs<AppRouter>
type RouterInputs = inferRouterInputs<AppRouter>
// Extract a single procedure's output type
type User = RouterOutputs['user']['byId'] // { id: string; name: string; createdAt: Date } | null
type UserList = RouterOutputs['user']['list'] // { items: User[]; nextCursor: string | undefined }
type PostInput = RouterInputs['post']['create'] // { title: string; content: string; ... }
// Use inferred types in React components
function UserProfile({ userId }: { userId: string }) {
// data is typed as User — no manual typing needed
const { data: user } = trpc.user.byId.useQuery({ id: userId })
return <h1>{user?.name}</h1>
}
// ── Passing inferred types to helper functions ───────────────
function formatUser(user: RouterOutputs['user']['byId']) {
if (!user) return 'Unknown'
// user.createdAt is Date (not string) — superjson preserved the type
return `${user.name} (joined ${user.createdAt.getFullYear()})`
}
// ── Type-safe optimistic updates ─────────────────────────────
import { useQueryClient } from '@tanstack/react-query'
import { getQueryKey } from '@trpc/react-query'
function LikeButton({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const queryKey = getQueryKey(trpc.post.byId, { id: postId }, 'query')
const like = trpc.post.like.useMutation({
onMutate: async () => {
await queryClient.cancelQueries({ queryKey })
const prev = queryClient.getQueryData(queryKey) as RouterOutputs['post']['byId']
// prev is fully typed — no 'as any' casts needed
queryClient.setQueryData(queryKey, {
...prev,
likes: (prev?.likes ?? 0) + 1,
})
return { prev }
},
onError: (_err, _vars, ctx) => {
// Rollback on error
if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev)
},
})
return <button onClick={() => like.mutate({ id: postId })}>Like</button>
}inferRouterOutputs and inferRouterInputs are utility types that extract all procedure input/output types at once — useful for building shared type utilities or passing procedure types to non-React consumers. The router type is a structural TypeScript type that the compiler resolves at build time; it adds zero bytes to the runtime JavaScript bundle. When a procedure's return type changes on the server, TypeScript immediately catches mismatches on the client in the same monorepo without any code generation step.
Batch Requests: Combining Multiple JSON Calls
When a page component calls several tRPC queries simultaneously, the HTTP batch link collects them into a single request, sending all inputs as a JSON array and receiving all results as a JSON array. This reduces the number of HTTP round-trips from N to 1 for concurrent queries, which matters significantly in server-rendered pages where every millisecond of latency affects Time to First Byte.
// ── Configuring httpBatchLink ────────────────────────────────
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import type { AppRouter } from '../server/router'
export const trpc = createTRPCReact<AppRouter>()
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
// Optional: disable batching for specific procedures
// headers: () => ({ 'x-trpc-batch-disable': '1' }),
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}
// ── Wire format for batched requests ─────────────────────────
// These two calls fire simultaneously in the same React render:
const { data: user } = trpc.user.byId.useQuery({ id: 'u1' })
const { data: posts } = trpc.post.list.useQuery({ limit: 10 })
// tRPC sends ONE request:
// GET /api/trpc/user.byId,post.list?batch=1&input={"0":{"json":{"id":"u1"}},"1":{"json":{"limit":10}}}
// Server response (one HTTP 200):
// [
// { "result": { "data": { "json": { "id": "u1", "name": "Alice" }, "meta": { "values": { "createdAt": ["Date"] } } } } },
// { "result": { "data": { "json": { "items": [...], "nextCursor": null } } } }
// ]
// ── httpBatchStreamLink — stream results as they complete ─────
import { httpBatchStreamLink } from '@trpc/client'
// tRPC v11: results stream back as individual procedures finish,
// instead of waiting for the slowest one.
// Uses Transfer-Encoding: chunked (Node.js) or ReadableStream (Edge).
const trpcStreamClient = trpc.createClient({
links: [
httpBatchStreamLink({
url: '/api/trpc',
transformer: superjson,
}),
],
})
// ── Disabling batching for specific queries ───────────────────
// Useful for expensive queries you don't want to block others:
const { data } = trpc.report.generate.useQuery(
{ type: 'annual' },
{
trpc: {
abortOnUnmount: true,
// Force this query onto its own HTTP request
},
}
)
// ── Server-side batch handling ────────────────────────────────
// app/api/trpc/[trpc]/route.ts handles all batched requests:
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/router'
const handler = (req: Request) => fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: async () => ({}),
// Batched procedures run concurrently on the server — no serial blocking
})
export { handler as GET, handler as POST }The batch link collects requests for one microtask tick. If two useQuery hooks render in the same React cycle, they batch together. Three or more also batch together. Procedures in a batch run concurrently on the server — one slow procedure does not block others from returning. httpBatchStreamLink (tRPC v11) goes further: individual procedure results stream back to the client as soon as they complete, instead of waiting for the slowest procedure in the batch. This reduces perceived latency when one query is significantly slower than others.
Integrating tRPC with Next.js App Router
tRPC v11 integrates with Next.js App Router through the Fetch API adapter, which turns the fetchRequestHandler into a standard Next.js Route Handler. Server Components call procedures directly via createCallerFactory — no HTTP round-trip. Client Components use React Query hooks through @trpc/react-query.
// ── app/api/trpc/[trpc]/route.ts — Route Handler ─────────────
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/router'
import { createContext } from '@/server/context'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req),
onError:
process.env.NODE_ENV === 'development'
? ({ error }) => console.error('tRPC Error:', error)
: undefined,
})
export { handler as GET, handler as POST }
// ── server/context.ts — request context for auth ─────────────
import { getServerSession } from 'next-auth'
import { db } from '@/lib/db'
export async function createContext(req: Request) {
const session = await getServerSession()
return { db, session, req }
}
export type Context = Awaited<ReturnType<typeof createContext>>
// ── Server Components — direct procedure calls (no HTTP) ─────
// app/users/[id]/page.tsx
import { createCallerFactory } from '@trpc/server'
import { appRouter } from '@/server/router'
import { createContext } from '@/server/context'
const createCaller = createCallerFactory(appRouter)
export default async function UserPage({ params }: { params: { id: string } }) {
const caller = createCaller(await createContext(/* server request */))
// Direct procedure call — no fetch, no JSON serialization
const user = await caller.user.byId({ id: params.id })
return <h1>{user?.name ?? 'Not found'}</h1>
}
// ── Client Components — React Query hooks ────────────────────
// components/user-profile.tsx
'use client'
import { trpc } from '@/utils/trpc'
export function UserProfile({ id }: { id: string }) {
const { data, isLoading } = trpc.user.byId.useQuery({ id })
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
trpc.useUtils().user.byId.invalidate({ id })
},
})
if (isLoading) return <div>Loading…</div>
return (
<div>
<h2>{data?.name}</h2>
<button
onClick={() => updateUser.mutate({ id, name: 'New Name' })}
disabled={updateUser.isPending}
>
Rename
</button>
</div>
)
}
// ── app/providers.tsx — TRPCProvider setup ───────────────────
'use client'
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import { trpc } from '@/utils/trpc'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
}))
const [trpcClient] = useState(() =>
trpc.createClient({
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}The createCallerFactory pattern is the recommended approach for Server Components — it calls procedures in-process with no serialization overhead and no network round-trip. Cache Server Component data with React.cache() or unstable_cache to prevent duplicate database calls across a single render pass. For pages that pre-fetch data using createServerSideHelpers (the prefetch pattern), pass the same superjson transformer to ensure the React Query cache receives correctly typed Date objects rather than strings.
Key Terms
- procedure
- A tRPC procedure is a single API endpoint defined on the server. It specifies an optional JSON input schema (via
.input()), its type (query for reads or mutation for writes), and a handler function. The handler receives the validated, typed input and a context object containing the database connection, session, and any other request-scoped dependencies. Procedures are grouped into routers and composed into a root router. The root router's TypeScript type — not its runtime value — is exported to the client, enabling end-to-end type inference. A query with no.input()call accepts no input. A mutation with.input(z.object({}))accepts an empty JSON object. - transformer
- A tRPC transformer is a plug-in serializer/deserializer that runs at the JSON boundary on both the server and the client. The default and recommended transformer is
superjson, which extends JSON's 6 native types to supportDate,Map,Set,BigInt,undefined, andError. A transformer must implement two methods:serialize(value)converts a JavaScript value to a JSON-safe structure, anddeserialize(json)reconstructs the original types. Transformers must be configured identically on the server (initTRPC.create({'{ transformer }'})) and the client (httpBatchLink({'{ transformer }'})) — a mismatch causes deserialization errors whereDateobjects appear as strings orSetobjects appear as arrays. - router
- A tRPC router is a named group of procedures, created by passing an object of procedures to
router({'{}'}). Routers can contain other routers as values, creating a namespace hierarchy —router({'{ user: userRouter, post: postRouter }'})produces endpoints reachable atuser.byId,post.list, etc. The root router is the top-level router passed to the HTTP handler. Its TypeScript type (typeof appRouter) is the only value exported to the client — the router's implementation, database connections, and server-side logic are never bundled into client code. The router type powers autocomplete and type checking for every procedure call on the client. - superjson
- superjson is a JSON serialization library that extends standard JSON to handle 7 additional JavaScript types:
Date,Map,Set,BigInt,undefined,RegExp, andError. It works by sending a two-field structure:jsoncontains the JSON-safe representation of the value (dates as ISO strings, sets as arrays, maps as array of pairs), andmeta.valuesis a path-to-type map describing which fields need special reconstruction. The deserializer reads themetaannotations and walks thejsontree, reconstructing original types at the annotated paths. superjson is used by tRPC as its default transformer and is also used independently in Next.js projects (e.g., with React Server Component data passing) anywhere extended JSON types need to cross a serialization boundary. - batch link
- The tRPC batch link (
httpBatchLink) is a client-side link that accumulates parallel procedure calls for one microtask tick and sends them in a single HTTP request as a JSON array. The request URL contains all procedure names separated by commas (/api/trpc/user.byId,post.list) and theinputquery parameter contains a JSON object mapping integer indices to individual inputs. The server processes all procedures concurrently and returns a JSON array of results in the same order. If any procedure fails, its result entry contains anerrorfield while successful entries contain aresultfield.httpBatchStreamLink(tRPC v11) extends this by streaming each result back as it completes, rather than waiting for all procedures to finish, using chunked transfer encoding or Server-Sent Events. - TRPCError
TRPCErroris the error class used inside tRPC procedure handlers to signal application-level errors. It accepts acodestring from a fixed set —BAD_REQUEST,UNAUTHORIZED,FORBIDDEN,NOT_FOUND,CONFLICT,INTERNAL_SERVER_ERROR, and others — and an optionalmessagestring. tRPC maps each code to an HTTP status code and serializes the error as a JSON response with a standardized shape. On the client,TRPCClientError.data.codecontains the string code for programmatic handling. Uncaught non-TRPCError exceptions in handlers are automatically wrapped asINTERNAL_SERVER_ERRORwith the message redacted in production (controlled by theonErrorcallback).
FAQ
How does tRPC serialize and deserialize data as JSON?
tRPC serializes every procedure input and output as JSON over HTTP — no manual JSON.stringify or JSON.parse at the call site. For query procedures, inputs are encoded as a JSON string in the ?input= query parameter. For mutations, inputs are sent as a JSON request body with Content-Type: application/json. The response body is always a JSON object with a result.data field containing the procedure's return value. By default, tRPC uses superjson as its data transformer, which extends standard JSON to handle types that JSON cannot natively represent: Date objects are serialized as ISO 8601 strings with type metadata, Map and Set become arrays with metadata, undefined is preserved (standard JSON strips it), and BigInt is encoded as a string. The transformer runs transparently on both the server and the client — you call a procedure and receive a fully typed value, with all Date objects already re-instantiated, not as strings. tRPC v11 uses the Fetch API internally, making it compatible with Next.js App Router, Cloudflare Workers, and Deno without additional adapters.
What is superjson and why does tRPC use it?
superjson is a serialization library that extends JSON to handle JavaScript types standard JSON cannot represent. JSON supports only 6 types: string, number, boolean, null, array, and object. superjson adds support for Date (serialized as ISO 8601 string + type annotation), Map, Set, BigInt, undefined, and Error objects. tRPC uses superjson as its default transformer because TypeScript APIs frequently work with Date objects for timestamps, Set for unique collections, and Map for key-value stores — types that would otherwise be silently corrupted by standard JSON round-trips. Without a transformer, a Date field would become a string on the client, requiring manual re-instantiation on every call. With superjson, the transformer runs at the serialization boundary on the server and deserialization on the client, so the client receives a proper Date object with no extra code. superjson achieves this by sending two parallel structures: the JSON-safe values and a meta.values type-annotations map. The combined payload is still valid JSON and passes through any HTTP proxy or CDN.
How do I validate JSON input in a tRPC procedure?
Pass a Zod schema to the .input() method of a procedure. tRPC calls schema.parse() on the incoming JSON before the procedure handler runs — invalid input never reaches your handler code. For example: publicProcedure.input(z.object({ id: z.string().uuid() })).query(({ input }) => db.user.findUnique({ where: { id: input.id } })). The input parameter inside the handler is typed as the inferred Zod type — no type assertions needed. When validation fails, tRPC returns a JSON error response with code: "BAD_REQUEST" and a zodError field containing flattened field errors. You can customize error messages by passing strings to Zod validators: z.string().uuid("Must be a valid UUID"). Beyond Zod, tRPC also supports Yup, Valibot, ArkType, and any validator that follows the SuperRefinement interface. For mutation procedures that accept complex payloads, use z.object() with nested objects and arrays — tRPC validates the entire JSON tree recursively before the handler runs.
What does a tRPC JSON error response look like?
tRPC error responses follow a standardized JSON shape. A BAD_REQUEST from failed Zod validation returns HTTP 400 with body: {"error":{"json":{"message":"id: Invalid uuid","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"user.byId","zodError":{"fieldErrors":{"id":["Invalid uuid"]},"formErrors":[]}}}}}. The error.json.data.code field uses tRPC's string codes — "BAD_REQUEST" (400), "UNAUTHORIZED" (401), "FORBIDDEN" (403), "NOT_FOUND" (404), "CONFLICT" (409), "TOO_MANY_REQUESTS" (429), "INTERNAL_SERVER_ERROR" (500). The error.json.code field uses JSON-RPC 2.0 numeric codes. On the client, thrown errors are TRPCClientError instances with a data property — use error.data?.code for programmatic error handling and error.data?.zodError?.fieldErrors to display per-field validation messages in forms.
How does tRPC infer TypeScript types from JSON outputs?
tRPC extracts the TypeScript type of every procedure's return value using inferAsyncReturnType and builds a router type that the client imports. The client receives 100% accurate types for every procedure's output with zero runtime cost — no code generation, no .d.ts files, no separate schema. Export the router type from the server: export type AppRouter = typeof appRouter. On the client, create a typed client: createTRPCClient<AppRouter>({ ... }). Now trpc.user.byId.useQuery({'{ id: "123" }'}) returns data typed as the exact return type of the byId procedure — including nullable fields, nested objects, and Date types after superjson deserialization. Use inferRouterOutputs and inferRouterInputs to extract procedure type maps: type Outputs = inferRouterOutputs<AppRouter>. If you change a procedure's return type on the server, TypeScript immediately flags mismatches on the client in the same monorepo.
How do tRPC batch requests work?
tRPC's httpBatchLink combines multiple parallel procedure calls into a single HTTP request as a JSON array, reducing round-trips when a page makes several queries simultaneously. When the client fires N queries in the same render cycle, tRPC waits one microtask tick, collects all pending requests, and sends them as a single GET: GET /api/trpc/user.byId,post.list?batch=1&input={"{"}"0":{"json":{"id":"1"}},"1":{"json":{}}{"}"}}. The server processes all procedures concurrently and returns a JSON array: [{"{"}"result":...{"}"},{"{"}"result":...{"}"}]. Each result maps to the corresponding request by index. If one procedure fails, only that entry contains an error field — the others succeed independently. httpBatchStreamLink (tRPC v11) streams results back as they complete, using chunked transfer encoding, instead of waiting for the slowest procedure in the batch. Configure by replacing httpBatchLink with httpBatchStreamLink in the client setup.
How do I use Date objects in tRPC without losing type information?
Configure superjson as the transformer in both the server and client setup. On the server: initTRPC.create({'{ transformer: superjson }'}). On the client: httpBatchLink({'{ url: "/api/trpc", transformer: superjson }'}). With superjson configured, a procedure returning { createdAt: new Date() } sends the date as an ISO 8601 string with a meta.values annotation: {"{"}"createdAt": ["Date"]{"}"}}. The client's superjson transformer reads the annotation and re-instantiates createdAt as a proper Date object before returning it to your component. The TypeScript type shows createdAt: Date — not string. The same mechanism works for Map, Set, BigInt, undefined, and Error. The transformer must be configured identically on both sides — a mismatch causes deserialization errors. For SSR with createServerSideHelpers, pass transformer: superjson there as well.
How do I integrate tRPC with Next.js App Router?
Create a Route Handler at app/api/trpc/[trpc]/route.ts using fetchRequestHandler from @trpc/server/adapters/fetch. Export both GET and POST from the handler function — tRPC uses GET for queries and POST for mutations: export { handler as GET, handler as POST }. The fetchRequestHandler uses the Web Fetch API, compatible with App Router's Request/Response model. For Server Components, use createCallerFactory to call procedures directly without a network round-trip — this is faster than fetching from the Route Handler and works within React's server rendering. For React Client Components, configure a TRPCProvider with httpBatchLink and use @trpc/react-query hooks: trpc.user.byId.useQuery({'{ id }'}). Cache Server Component data with React.cache() to deduplicate database calls across a single render. For SSR pre-population, use createServerSideHelpers with the same superjson transformer.
Validate and format your tRPC JSON payloads
Paste any JSON object into Jsonic to format, validate, and inspect your tRPC request and response bodies instantly.
Open JSON FormatterFurther reading and primary sources
- tRPC Documentation — Official tRPC docs covering routers, procedures, context, middleware, and adapters
- superjson GitHub Repository — superjson source code, supported types, and custom transformer registration
- tRPC v11 Migration Guide — Breaking changes from tRPC v10 to v11: Fetch API adapter, httpBatchStreamLink, and transformer changes
- tRPC + Next.js App Router Example — Official tRPC example project using Next.js App Router with Server Components and Client Components
- Zod Documentation — Official Zod docs for input validation schemas used with tRPC .input()