Fix: tRPC Not Working — Type Inference Lost, Procedure Not Found, or Context Not Available
Part of: React & Frontend Errors
Quick Answer
How to fix tRPC issues — router setup, type inference across packages, context injection, middleware, error handling, and common tRPC v10/v11 configuration mistakes.
The Problem
tRPC procedure types aren’t inferred on the client:
// server/router.ts
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => getUserById(input.id)),
});
// client — no autocomplete, types are 'any'
const { data } = trpc.getUser.useQuery({ id: '1' });
// data is 'any' — type inference not workingOr a procedure throws “No procedure found”:
TRPCError: No "query"-procedure on path "user.getById"Or context isn’t populated in procedures:
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Inside procedure: ctx.user is undefined despite middleware runningWhy This Happens
tRPC’s type safety depends on a strict connection between server and client. There is no schema file, no codegen step, no IDL — the server exports type AppRouter = typeof appRouter and the client imports that type with import type { AppRouter }. Everything else (input validation, response shape, error codes) flows from that single type. When inference is “lost,” it always means the type went somewhere it shouldn’t, or did not arrive where it should have.
The most common cause is module-resolution misconfiguration. If your tsconfig paths map points the client to a build artifact (a dist/ folder) rather than the source src/router.ts, the client gets whatever TypeScript could infer from emitted .d.ts files. Those files often degrade complex inference into any or strip generics. Equally common: the client side has skipLibCheck: true and a different strict setting than the server, so types resolve to subtly different shapes. Run tsc --noEmit from the client package with --listFiles to verify which copy of router.ts you are actually importing.
The runtime failures are usually wiring failures. createContext lives in two places — the function that builds the initial context, and the adapter (createNextApiHandler, fetchRequestHandler, createHTTPHandler) that calls it on every request. If you forget to pass createContext to the adapter, every procedure sees an empty ctx. If you set up middleware before the context type is propagated, TypeScript shows ctx.user as unknown. And tRPC v10 vs v11 introduced significant API changes — httpBatchLink moved, the initTRPC.create() builder pattern was refined, and the React adapter split into @trpc/react-query vs @trpc/tanstack-react-query. Mixing major versions across a monorepo guarantees confusing runtime errors that look like type errors.
- Type inference breaks when
AppRoutertype isn’t shared correctly — the client must import theAppRoutertype (not the value) from the server. If you import the router value or use a separate type definition, inference breaks. - Nested routers require dot-notation paths —
router({ user: userRouter })creates ausernamespace. The client accesses it astrpc.user.getById, nottrpc.getById. - Context is set up in two places — the
createContextfunction provides initial context, and middleware can augment it. IfcreateContextisn’t wired to the HTTP adapter, context is empty in all procedures. - tRPC v10 vs v11 breaking changes — tRPC v11 has different initialization patterns. Mixing v10 and v11 APIs causes runtime errors and type mismatches.
Fix 1: Share AppRouter Type Correctly
The client must receive the AppRouter type — not the router instance:
// server/router.ts — export the type
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return getUserById(input.id);
}),
});
// Export the TYPE — this is what the client imports
export type AppRouter = typeof appRouter;
// client/trpc.ts — import the TYPE only
import type { AppRouter } from '../server/router'; // 'import type' — no runtime import
import { createTRPCReact } from '@trpc/react-query';
export const trpc = createTRPCReact<AppRouter>();
// Now autocomplete and type inference work
const { data } = trpc.getUser.useQuery({ id: '1' });
// data: { id: string; name: string; email: string } | undefinedFor monorepos — export from a shared package:
// packages/api/src/router.ts
export const appRouter = /* ... */;
export type AppRouter = typeof appRouter;
// apps/web/src/trpc.ts
import type { AppRouter } from '@my-app/api';
import { createTRPCReact } from '@trpc/react-query';
export const trpc = createTRPCReact<AppRouter>();Note: Use
import typeto ensure the router code isn’t bundled into the client. The router contains server-only code (database queries, secrets) that must never reach the browser.
Fix 2: Fix Nested Router Paths
When you compose routers, access them with the correct dot-notation path:
// server/routers/user.ts
export const userRouter = router({
getById: publicProcedure
.input(z.string())
.query(({ input }) => getUserById(input)),
list: publicProcedure
.query(() => getAllUsers()),
});
// server/routers/post.ts
export const postRouter = router({
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(({ input, ctx }) => createPost(input, ctx.user.id)),
});
// server/router.ts — compose routers
export const appRouter = router({
user: userRouter, // Namespace: 'user'
post: postRouter, // Namespace: 'post'
});
// client — use dot-notation for nested routers
const user = await trpc.user.getById.query('user-123');
const users = await trpc.user.list.query();
await trpc.post.create.mutate({ title: 'Hello', content: 'World' });
// React Query hooks
const { data: user } = trpc.user.getById.useQuery('user-123');
const { mutate: createPost } = trpc.post.create.useMutation();Fix 3: Set Up Context Correctly
Context flows from createContext through the HTTP adapter to every procedure:
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
export async function createContext({ req, res }: CreateNextContextOptions) {
// Parse auth token from request
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
return {
req,
res,
user, // null if not authenticated
db, // Database connection
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
// server/trpc.ts — wire context to procedures
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// Protected procedure with context augmentation
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
// TypeScript now knows ctx.user is non-null downstream
return next({
ctx: {
...ctx,
user: ctx.user, // Narrow the type
},
});
});
// pages/api/trpc/[trpc].ts — wire createContext to the adapter
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/router';
import { createContext } from '../../../server/context';
export default createNextApiHandler({
router: appRouter,
createContext, // This is where context gets populated per-request
onError: ({ error, path }) => {
console.error(`Error on /${path}:`, error);
},
});Fix 4: Handle Errors Properly
tRPC errors must use TRPCError with predefined codes for correct HTTP status mapping:
import { TRPCError } from '@trpc/server';
export const userRouter = router({
getById: publicProcedure
.input(z.string().uuid())
.query(async ({ input }) => {
const user = await db.users.findUnique({ where: { id: input } });
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${input} not found`,
});
}
return user;
}),
update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
// Check ownership
if (input.id !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only update your own profile',
});
}
try {
return await db.users.update({
where: { id: input.id },
data: { name: input.name },
});
} catch (e) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to update user',
cause: e,
});
}
}),
});
// Client-side error handling
const { mutate, error } = trpc.user.update.useMutation({
onError: (err) => {
if (err.data?.code === 'FORBIDDEN') {
toast.error('You can only update your own profile');
} else if (err.data?.code === 'NOT_FOUND') {
toast.error('User not found');
} else {
toast.error('Something went wrong');
}
},
});tRPC error codes and their HTTP status:
| tRPC Code | HTTP Status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHORIZED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
CONFLICT | 409 |
PRECONDITION_FAILED | 412 |
PAYLOAD_TOO_LARGE | 413 |
METHOD_NOT_SUPPORTED | 405 |
TIMEOUT | 408 |
UNPROCESSABLE_CONTENT | 422 |
TOO_MANY_REQUESTS | 429 |
INTERNAL_SERVER_ERROR | 500 |
Fix 5: Client Setup for Different Frameworks
Next.js App Router with tRPC v11:
// src/trpc/server.ts — server-side caller
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/router';
export const api = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
// For App Router server components, pass cookies:
headers: () => {
const heads = new Headers();
heads.set('x-trpc-source', 'rsc');
return Object.fromEntries(heads);
},
}),
],
});
// src/trpc/react.tsx — client-side React Query integration
'use client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router';
export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/trpc/react';
import { useState } from 'react';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({ url: '/api/trpc' }),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Subscriptions (WebSocket):
// server — add subscription procedure
export const appRouter = router({
onNewMessage: publicProcedure
.input(z.object({ channelId: z.string() }))
.subscription(({ input }) => {
return observable<Message>((emit) => {
const unsubscribe = messageEmitter.on(input.channelId, (msg) => {
emit.next(msg);
});
return unsubscribe;
});
}),
});
// client — WebSocket link alongside HTTP link
const wsClient = createWSClient({ url: 'ws://localhost:3000' });
const client = trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({ client: wsClient }),
false: httpBatchLink({ url: '/api/trpc' }),
}),
],
});
// React component
function MessageFeed({ channelId }: { channelId: string }) {
trpc.onNewMessage.useSubscription(
{ channelId },
{
onData: (message) => setMessages(prev => [...prev, message]),
onError: (err) => console.error(err),
}
);
}Fix 6: Input Validation and Transformation
Zod input validation errors are automatically returned as BAD_REQUEST:
// Reusable input schemas
const paginationInput = z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
});
const userIdInput = z.string().uuid('Invalid user ID format');
export const userRouter = router({
list: publicProcedure
.input(paginationInput)
.query(async ({ input }) => {
const { page, limit } = input; // Typed and validated
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
db.users.findMany({ skip: offset, take: limit }),
db.users.count(),
]);
return {
users,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
};
}),
// Optional input
search: publicProcedure
.input(z.object({
query: z.string().min(1),
filters: z.object({
role: z.enum(['admin', 'user']).optional(),
active: z.boolean().optional(),
}).optional(),
}))
.query(({ input }) => searchUsers(input)),
});tRPC vs GraphQL vs REST + Zod vs gRPC-Web vs ts-rest
The “typed API between client and server” problem has five real solutions in TypeScript land. Each one trades the same axes: who generates the types, whether there is a runtime schema, and whether non-TypeScript clients are first-class.
tRPC shares TypeScript types directly. No IDL, no codegen, no runtime schema for the procedure shape itself (though inputs are validated with Zod or Valibot). The win is that you write a function on the server and get fully typed useQuery/useMutation hooks on the client for free. The constraint is that both ends must be TypeScript and must share a build graph. Mobile clients in Swift or Kotlin cannot meaningfully consume tRPC.
GraphQL (Apollo, Relay, urql, gql.tada) uses a schema-first or code-first SDL, then generates types via @graphql-codegen or infers them at compile time with gql.tada. It pays off when you have many heterogeneous clients (web, iOS, Android, third-party), or when the client wants to select exactly which fields it needs to avoid over-fetching. The cost is operational: schema management, persisted queries, the N+1 problem, and a much bigger client bundle than tRPC. See Fix: GraphQL Error Handling Not Working for the layered-error model that catches teams off guard.
REST + Zod (or OpenAPI + zodios/Hono) is the “use what you have” answer. You write Zod schemas, expose REST endpoints with Hono or Fastify, and either share the Zod schema directly (TS-to-TS) or emit an OpenAPI document for non-TS clients. With @asteasolutions/zod-to-openapi or Hono’s OpenAPIHono, this gives you a typed client in TS and a contract that Swift/Kotlin codegen can consume. The trade-off is more boilerplate per endpoint compared to tRPC.
gRPC-Web uses protobuf as the schema and generates clients in every major language. It is the right choice when you genuinely need cross-language type sharing, streaming RPCs, or are building inside a microservices fleet that already speaks gRPC. The downside on the browser side is heavy code generation, binary payloads that are harder to debug, and a proxy requirement (Envoy or grpc-web-proxy) because browsers cannot speak raw HTTP/2 trailers.
ts-rest is the “REST contract with tRPC ergonomics” middle ground. You declare a contract object with initContract().router({ ... }), both ends import the contract, and the server implements it while the client gets a typed client.users.getById({ params: { id } }). Unlike tRPC it produces real REST URLs with HTTP verbs and standard status codes, so it plays nicely with API gateways, Postman, and non-TS consumers via OpenAPI export. Like tRPC it requires both ends to share TypeScript for the typed client.
| Approach | Type sharing | Runtime schema | Non-TS clients | Browser bundle | Best for |
|---|---|---|---|---|---|
| tRPC | TS-only, structural | Inputs (Zod) only | No | Small | TS-to-TS apps, monorepos |
| GraphQL | Schema → codegen | Full SDL | First-class | Medium-Large | Many client kinds, field selection |
| REST + Zod | Optional via Zod export | Zod or OpenAPI | Via OpenAPI codegen | Tiny | Public APIs, mixed clients |
| gRPC-Web | Protobuf → codegen | Protobuf descriptors | First-class | Medium (with proxy) | Polyglot microservices |
| ts-rest | TS contract object | Optional (Zod) | Via OpenAPI export | Small | REST shape with TS DX |
Type sharing also splits into “codegen” vs “runtime” camps. Codegen tools (GraphQL Code Generator, gRPC-Web, OpenAPI Generator) produce committed files you can read and version-control. Runtime/structural tools (tRPC, ts-rest, zodios) infer types via typeof appRouter and never emit a file. Codegen wins when you want explicit diffs in PRs and language-agnostic outputs. Runtime inference wins when you want zero build steps and immediate IDE feedback as you edit the server. If your bug is “I changed the server but the client still has stale types,” you are on a codegen system and forgot the regenerate step; if your bug is “the client compiles but blows up at runtime with a missing field,” you are on a runtime-inference system and forgot to validate inputs with Zod.
Still Not Working?
“Transformer” error when using superjson — if you use superjson as a transformer (for Date, Map, Set support), it must be set on both the server initTRPC.create({ transformer: superjson }) and the client link httpBatchLink({ url: '/api/trpc', transformer: superjson }). Mismatched transformers cause parse errors.
Procedures work in development but return 404 in production — check that your tRPC API route is included in the production build. In Next.js, ensure pages/api/trpc/[trpc].ts (Pages Router) or app/api/trpc/[trpc]/route.ts (App Router) is present. The dynamic route segment [trpc] handles all procedure paths.
Batch requests disabled by middleware — some reverse proxies (nginx, Cloudflare Workers) have request size limits that reject batched tRPC requests. Set httpBatchLink with maxURLLength to prevent URL-length-based batching issues, or use httpLink instead of httpBatchLink to disable batching entirely.
Server-side calls inside React Server Components run the wrong context — when you instantiate a server-side caller (appRouter.createCaller(ctx)) inside an RSC, the ctx you pass needs the request’s cookies/headers, which RSC does not expose synchronously. Use next/headers (cookies(), headers()) inside createContext and call createCaller lazily per request — never cache the caller at module scope, or every user inherits the first request’s auth.
Type inference is correct in the IDE but wrong at build time — this almost always points at multiple copies of @trpc/server in node_modules. Run npm ls @trpc/server (or pnpm/yarn equivalent) and verify a single version. The TS server uses the latest copy it can resolve from any path, so editor hints can hide that your client package depends on an older tRPC than the server package.
Procedures hang when called from a streaming server framework — Hono on Cloudflare Workers, Bun.serve, and Deno’s Deno.serve each handle async iterators differently than Node’s http. If a subscription works locally on Node but never emits in production on Workers, the platform may be holding the response open without flushing. Switch from httpSubscriptionLink to wsLink against a Durable Object or hosted WebSocket, or use unstable_httpSubscriptionLink with keepAlive events to keep the connection from being garbage-collected.
For related TypeScript issues, see Fix: TypeScript Discriminated Union Error, Fix: NestJS Validation Pipe Not Working, Fix: Zod Validation Not Working, and Fix: GraphQL Error Handling Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.
Fix: date-fns Not Working — Wrong Timezone Output, Invalid Date, or Locale Not Applied
How to fix date-fns issues — timezone handling with date-fns-tz, parseISO vs new Date, locale import and configuration, DST edge cases, v3 ESM migration, and common format pattern mistakes.