Fix: tRPC Not Working — Type Inference Lost, Procedure Not Found, or Context Not Available
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:
- 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)),
});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.
For related TypeScript issues, see Fix: TypeScript Discriminated Union Error and Fix: NestJS Validation Pipe 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.