End-to-End Typesafe APIs with tRPC

27. September, 2025 11 min read Develop

End-to-End Typesafe APIs

Building APIs in a full-stack TypeScript application often means maintaining type definitions in two places — once on the server and once on the client. tRPC eliminates this duplication entirely by letting your client infer types directly from your server code, with zero code generation and zero schema definitions.

tRPC (TypeScript Remote Procedure Call) shifts how you think about API development. Instead of designing REST endpoints or writing GraphQL schemas, you define server functions and call them from the client as if they were local. In this post, we’ll explore tRPC v11, set it up with Next.js, and walk through the patterns that make it powerful.

Why tRPC?

To understand tRPC’s value, consider how a typical REST API works in a TypeScript project. You define an endpoint on the server, then create matching type definitions on the client for the request and response shapes. When the API changes, you need to update both sides — and if you forget, the mismatch shows up as a runtime error rather than a compile-time one.

GraphQL improves on this with a schema-first approach, but it introduces its own complexity: a schema definition language, resolvers, and a code generation step (typically graphql-codegen) to produce TypeScript types from the schema.

tRPC takes a different approach entirely. It uses TypeScript’s type inference to keep the API contract synchronized between client and server automatically. There’s no schema to write, no types to generate, and no build step beyond your normal TypeScript compilation. When you change a server procedure, your client code immediately shows type errors if the call sites are incompatible.

The trade-off is that tRPC only works when both client and server are TypeScript. For public APIs consumed by third-party clients in other languages, REST with OpenAPI or GraphQL remain better choices. But for full-stack TypeScript applications — which describes most Next.js projects — tRPC is a natural fit.

Core Concepts

tRPC is built around a few key concepts:

  • Procedures — Individual API endpoints. Queries read data, mutations write data, and subscriptions provide real-time updates.
  • Routers — Namespaced collections of procedures that can be nested for organization.
  • Context — Per-request shared data (authentication state, database connections) available to all procedures.
  • Middleware — Functions that run before procedures to handle cross-cutting concerns like auth, logging, or rate limiting.
  • Validation — Input and output schema verification using Zod or any compatible library.

Setting Up tRPC with Next.js

Let’s walk through setting up tRPC in a Next.js App Router project.

1. Install Dependencies

npm install @trpc/server @trpc/client @trpc/tanstack-react-query \
  @tanstack/react-query zod server-only client-only

2. Initialize tRPC

Create the tRPC instance that all your routers and procedures will use:

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

export type Context = {
  user: { id: string; name: string; role: string } | null;
  db: typeof db;
};

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

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

3. Create a Router

Define your API procedures using the builder pattern:

// server/routers/users.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

export const usersRouter = router({
  list: publicProcedure.query(async ({ ctx }) => {
    return ctx.db.user.findMany();
  }),

  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
      });

      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found',
        });
      }

      return user;
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1),
        email: z.string().email(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.create({ data: input });
    }),
});

Notice how queries handle reads and mutations handle writes. The .input() method accepts a Zod schema that validates incoming data and provides full type inference — input is automatically typed as { name: string; email: string } in the mutation handler.

4. Create the App Router

Combine your routers into a single app router:

// server/routers/index.ts
import { router } from '../trpc';
import { usersRouter } from './users';
import { postsRouter } from './posts';

export const appRouter = router({
  users: usersRouter,
  posts: postsRouter,
});

// Export only the TYPE - never export the implementation
export type AppRouter = typeof appRouter;

This is a critical rule: only export the type of the router. Exporting the implementation would cause server code to be bundled into the client.

5. Create the API Route

Set up the Next.js route handler that serves the tRPC API:

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

6. Set Up the Client

Create the tRPC client with React Query integration:

// lib/trpc.ts
'use client';

import { createTRPCContext } from '@trpc/tanstack-react-query';
import type { AppRouter } from '@/server/routers';

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { TRPCProvider } from '@/lib/trpc';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    TRPCProvider.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider client={trpcClient} queryClient={queryClient}>
        {children}
      </TRPCProvider>
    </QueryClientProvider>
  );
}

Using tRPC in Components

With the setup complete, calling your API from components is straightforward:

'use client';

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc';

export function UserList() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const { data: users, isPending } = useQuery(
    trpc.users.list.queryOptions()
  );

  const createUser = useMutation(
    trpc.users.create.mutationOptions({
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: trpc.users.list.queryKey() });
      },
    })
  );

  if (isPending) return <div>Loading...</div>;

  return (
    <div>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <button
        onClick={() =>
          createUser.mutate({ name: 'New User', email: 'new@example.com' })
        }
      >
        Add User
      </button>
    </div>
  );
}

Everything is fully typed. If you change the input schema of users.create on the server, the createUser.mutate() call will immediately show a type error. No code generation, no manual type syncing.

Context and Authentication

Context is created per request and passed to all procedures. This is where you handle authentication:

// server/context.ts
import { auth } from '@/lib/auth';

export const createContext = async (opts: { req: Request }) => {
  const session = await auth(opts.req);

  return {
    user: session?.user ?? null,
    db,
  };
};

Middleware

Middleware lets you create reusable procedure variants. The most common pattern is an authenticated procedure:

// server/trpc.ts
export const authedProcedure = publicProcedure.use(async (opts) => {
  if (!opts.ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }

  return opts.next({
    ctx: { user: opts.ctx.user }, // user is now non-nullable
  });
});

export const adminProcedure = authedProcedure.use(async (opts) => {
  if (opts.ctx.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }

  return opts.next();
});

Use these in your routers to enforce access control:

export const usersRouter = router({
  list: publicProcedure.query(/* anyone can list */),
  create: authedProcedure.mutation(/* must be logged in */),
  delete: adminProcedure.mutation(/* admins only */),
});

The type system works here too. Inside authedProcedure, ctx.user is guaranteed to be non-null. Inside adminProcedure, the user is guaranteed to have the admin role. This eliminates runtime null checks that would otherwise clutter your handler code.

You can also create utility middleware for cross-cutting concerns:

const loggedProcedure = publicProcedure.use(async (opts) => {
  const start = Date.now();
  const result = await opts.next();
  console.log(`${opts.path} completed in ${Date.now() - start}ms`);
  return result;
});

Error Handling

tRPC uses TRPCError with standard error codes that map to HTTP status codes:

import { TRPCError } from '@trpc/server';

// In a procedure
throw new TRPCError({
  code: 'NOT_FOUND',       // 404
  message: 'Post not found',
});

throw new TRPCError({
  code: 'BAD_REQUEST',     // 400
  message: 'Invalid input',
});

throw new TRPCError({
  code: 'FORBIDDEN',       // 403
  message: 'Access denied',
});

On the client, errors are surfaced through React Query’s standard error handling:

const { data, error } = useQuery(trpc.users.byId.queryOptions({ id }));

if (error) {
  // error.data.code contains 'NOT_FOUND', 'UNAUTHORIZED', etc.
  return <div>Error: {error.message}</div>;
}

For global error handling, use the onError callback in your API route handler:

fetchRequestHandler({
  endpoint: '/api/trpc',
  req,
  router: appRouter,
  createContext,
  onError: ({ error, path }) => {
    console.error(`tRPC error on ${path}:`, error);
  },
});

Server Component Prefetching

tRPC integrates with Next.js Server Components for server-side data prefetching, avoiding client-side loading waterfalls:

// app/users/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { appRouter } from '@/server/routers';
import { createContext } from '@/server/context';
import { getQueryClient } from '@/lib/query-client';
import { UserList } from './user-list';

const trpc = createTRPCOptionsProxy<typeof appRouter>();

export default async function UsersPage() {
  const queryClient = getQueryClient();

  void queryClient.prefetchQuery(trpc.users.list.queryOptions());

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />
    </HydrationBoundary>
  );
}

The data is fetched on the server and dehydrated into the HTML. When the client component hydrates, it immediately has the data available — no loading spinner, no extra network request.

Performance Features

tRPC v11 includes several features for optimizing performance:

  • Request batching: httpBatchLink combines multiple concurrent tRPC calls into a single HTTP request, reducing network overhead.
  • Streaming responses: httpBatchStreamLink streams responses as they resolve, so fast queries don’t wait for slow ones.
  • SSE subscriptions: Server-Sent Events for real-time updates without a WebSocket server.
  • Lazy-loading routers: Code-split large router trees to reduce server bundle size.
// Using streaming batch link
import { httpBatchStreamLink } from '@trpc/client';

TRPCProvider.createClient({
  links: [
    httpBatchStreamLink({
      url: '/api/trpc',
    }),
  ],
});

tRPC vs REST vs GraphQL

Each approach has its sweet spot:

tRPC REST GraphQL
Type safety Automatic, zero-config Manual or via OpenAPI codegen Via schema + codegen
Setup complexity Low Low Medium-High
Best for Full-stack TS apps Public APIs, polyglot teams Complex data graphs, mobile
Schema None (inferred) OpenAPI (optional) SDL (required)
Code generation None Optional Typically required
Learning curve Low (if you know TS) Low Medium

tRPC excels when your entire stack is TypeScript and you want maximum developer velocity with minimal boilerplate. It’s not the right choice for public APIs that need to serve clients in multiple languages, but for internal APIs in a Next.js monolith or monorepo, it’s hard to beat.

Output Validation

While input validation is the most common use case, tRPC also supports output validation to ensure your procedures return the expected shape. This is particularly valuable for catching regressions when your database schema changes:

const userOutput = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

export const usersRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .output(userOutput)
    .query(async ({ ctx, input }) => {
      return ctx.db.user.findUnique({ where: { id: input.id } });
    }),
});

If the procedure returns data that doesn’t match the output schema — say, a new field was added to the database but shouldn’t be exposed to clients — tRPC returns an INTERNAL_SERVER_ERROR rather than leaking unexpected data. This acts as a safety net for API contracts.

Testing Procedures

One of tRPC’s underappreciated strengths is testability. Since procedures are just functions that accept context and input, you can test them directly without spinning up an HTTP server:

import { appRouter } from '@/server/routers';

describe('users router', () => {
  it('creates a user', async () => {
    const caller = appRouter.createCaller({
      user: { id: '1', name: 'Test', role: 'admin' },
      db: mockDb,
    });

    const result = await caller.users.create({
      name: 'New User',
      email: 'test@example.com',
    });

    expect(result.name).toBe('New User');
  });

  it('throws on unauthorized access', async () => {
    const caller = appRouter.createCaller({ user: null, db: mockDb });

    await expect(caller.users.delete({ id: '1' })).rejects.toThrow(
      'UNAUTHORIZED'
    );
  });
});

The createCaller method lets you invoke procedures with a specific context, making it trivial to test authentication, authorization, and business logic in isolation.

Conclusion

tRPC brings a level of developer experience to API development that’s hard to appreciate until you’ve used it. The instant feedback loop — change a server procedure and see type errors in your client code before you even save — eliminates an entire class of bugs that traditionally only surface at runtime.

Combined with Next.js App Router, React Query, and Zod validation, tRPC provides a complete full-stack toolkit where types flow seamlessly from database to UI. If you’re building a TypeScript-first application, tRPC is worth adopting for the productivity gains alone.

‘Till next time!