Skip to content

Fix: tRPC Not Working — Type Inference Lost, Procedure Not Found, or Context Not Available

FixDevs ·

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 working

Or 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 running

Why This Happens

tRPC’s type safety depends on a strict connection between server and client:

  • Type inference breaks when AppRouter type isn’t shared correctly — the client must import the AppRouter type (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 pathsrouter({ user: userRouter }) creates a user namespace. The client accesses it as trpc.user.getById, not trpc.getById.
  • Context is set up in two places — the createContext function provides initial context, and middleware can augment it. If createContext isn’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 } | undefined

For 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 type to 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 CodeHTTP Status
BAD_REQUEST400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
CONFLICT409
PRECONDITION_FAILED412
PAYLOAD_TOO_LARGE413
METHOD_NOT_SUPPORTED405
TIMEOUT408
UNPROCESSABLE_CONTENT422
TOO_MANY_REQUESTS429
INTERNAL_SERVER_ERROR500

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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles