← All articles
BACKEND tRPC: End-to-End Type-Safe APIs Without the Ceremony 2026-03-04 · 6 min read · trpc · typescript · api

tRPC: End-to-End Type-Safe APIs Without the Ceremony

Backend 2026-03-04 · 6 min read trpc typescript api type-safety react nextjs full-stack

tRPC: End-to-End Type-Safe APIs Without the Ceremony

Most API architectures have a type safety gap: your backend has TypeScript types, your frontend has TypeScript types, but the wire between them is untyped. You maintain REST endpoints, write OpenAPI specs, run code generators, or manually keep client types in sync with server responses. When something drifts, you get runtime errors.

tRPC eliminates this entirely. It's a TypeScript-first RPC framework where your router functions become the API contract. No schemas, no code generation — if you call a procedure that doesn't exist or pass the wrong argument type, TypeScript tells you at compile time.

The Core Idea

With tRPC:

  1. You define procedures on the server (typed functions with input/output types)
  2. The frontend imports the router type (not the implementation)
  3. TypeScript infers the full type signature of every procedure on the client
  4. Your IDE autocompletes procedure names, input shapes, and return types

No generated files, no schema files, no manual sync — TypeScript does the work.

When to Use tRPC

tRPC is ideal when:

It's NOT ideal for:

Installation

In a New Project (with Next.js)

The easiest start is create-t3-app, which scaffolds a full tRPC + Next.js + TypeScript project:

npm create t3-app@latest

Adding tRPC to an Existing Project

Install the packages:

# Core
npm install @trpc/server @trpc/client

# React Query integration (for React frontends)
npm install @trpc/react-query @tanstack/react-query

# Zod for input validation
npm install zod

Server Setup

Create your tRPC router on the server side.

1. Initialize tRPC

// src/server/trpc.ts
import { initTRPC } from '@trpc/server';

// Create tRPC instance
const t = initTRPC.create();

// Export reusable helpers
export const router = t.router;
export const publicProcedure = t.procedure;

2. Define Your Router

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

export const userRouter = router({
  // Query procedure (read)
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),

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

  // List with filtering
  list: publicProcedure
    .input(z.object({
      search: z.string().optional(),
      limit: z.number().min(1).max(100).default(20),
    }))
    .query(async ({ input }) => {
      return db.user.findMany({
        where: input.search
          ? { name: { contains: input.search, mode: 'insensitive' } }
          : undefined,
        take: input.limit,
      });
    }),
});

3. Combine Routers

// src/server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { productRouter } from './product';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
  product: productRouter,
});

// Export type — this is shared with the client
export type AppRouter = typeof appRouter;

4. Expose the Handler (Next.js API Route)

// src/app/api/trpc/[trpc]/route.ts  (Next.js App Router)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';

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

export { handler as GET, handler as POST };

Client Setup (React + React Query)

// src/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

Wrap Your App with Providers

// src/app/providers.tsx
'use client';

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

export function Providers({ 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>
  );
}

Using tRPC in Components

// src/components/UserList.tsx
'use client';

import { trpc } from '@/trpc/client';

export function UserList() {
  // TypeScript knows the return type — no manual type annotation needed
  const { data: users, isLoading, error } = trpc.user.list.useQuery({
    limit: 20,
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {users?.map((user) => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  );
}
// src/components/CreateUser.tsx
'use client';

import { trpc } from '@/trpc/client';

export function CreateUser() {
  const utils = trpc.useUtils();

  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Invalidate and refetch user list
      utils.user.list.invalidate();
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = new FormData(e.currentTarget);

    createUser.mutate({
      name: form.get('name') as string,
      email: form.get('email') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
      {createUser.error && <p>{createUser.error.message}</p>}
    </form>
  );
}

Adding Authentication Context

Most apps need auth context in procedures. Add it through the tRPC context:

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';

interface CreateContextOptions {
  req: Request;
}

export const createContext = async ({ req }: CreateContextOptions) => {
  const session = await getServerSession();
  return { session };
};

type Context = Awaited<ReturnType<typeof createContext>>;

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

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

// Protected procedure — throws if not authenticated
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      ...ctx,
      session: ctx.session,  // Now non-null
    },
  });
});

Use in your router:

export const postRouter = router({
  // Anyone can read
  list: publicProcedure.query(() => db.post.findMany()),

  // Must be logged in to create
  create: protectedProcedure
    .input(z.object({ title: z.string(), body: z.string() }))
    .mutation(async ({ input, ctx }) => {
      return db.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
        },
      });
    }),
});

Input Validation with Zod

tRPC's Zod integration handles validation and provides TypeScript types automatically:

const createProductInput = z.object({
  name: z.string().min(1).max(100),
  price: z.number().positive(),
  category: z.enum(['electronics', 'clothing', 'food']),
  tags: z.array(z.string()).max(10).optional(),
  publishedAt: z.date().optional(),
});

// TypeScript infers the type from Zod schema
type CreateProductInput = z.infer<typeof createProductInput>;

export const productRouter = router({
  create: protectedProcedure
    .input(createProductInput)
    .mutation(async ({ input }) => {
      // input is fully typed: CreateProductInput
      return db.product.create({ data: input });
    }),
});

Validation errors are automatically returned to the client with field-level details and the appropriate HTTP status code.

Subscriptions (WebSocket)

tRPC supports real-time subscriptions:

// Server
import { observable } from '@trpc/server/observable';

export const chatRouter = router({
  onMessage: publicProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(({ input }) => {
      return observable<{ user: string; text: string }>((emit) => {
        const unsubscribe = messageEmitter.on(input.roomId, (message) => {
          emit.next(message);
        });
        return unsubscribe;
      });
    }),
});

// Client
const { data } = trpc.chat.onMessage.useSubscription(
  { roomId: 'general' },
  {
    onData: (message) => {
      setMessages((prev) => [...prev, message]);
    },
  }
);

Error Handling

tRPC uses typed errors:

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

// In a procedure
if (!foundItem) {
  throw new TRPCError({
    code: 'NOT_FOUND',
    message: 'Item not found',
  });
}

// On the client
const { error } = trpc.item.get.useQuery({ id });
if (error?.data?.code === 'NOT_FOUND') {
  return <div>This item doesn't exist</div>;
}

TRPC error codes map to HTTP status codes automatically: NOT_FOUND → 404, UNAUTHORIZED → 401, BAD_REQUEST → 400.

tRPC vs REST vs GraphQL

Aspect tRPC REST GraphQL
Type safety ✅ Native ❌ Manual ✅ With codegen
Schema required ❌ No Optional ✅ Required
Code generation ❌ None Optional Usually required
Multi-language clients ❌ TS only
Learning curve Low Very low Medium-high
Over/under-fetching N/A Common Avoided
Caching Via React Query HTTP cache Apollo/urql

Use tRPC when: TypeScript monorepo, internal API, team prefers simplicity. Use REST when: public API, multi-language clients, needs HTTP caching. Use GraphQL when: complex data graph, many clients with different data needs, team has GraphQL expertise.

Wrapping Up

tRPC makes the "glue" between frontend and backend invisible. You write server functions, you call them from the client — TypeScript handles the contract enforcement. There's no spec to maintain, no types to sync, and no code to generate.

For TypeScript full-stack projects, especially those in a monorepo, tRPC dramatically reduces the overhead of API development and eliminates an entire class of runtime bugs before they reach production.


Type-safe API patterns and more TypeScript tooling in the DevTools Guide newsletter — subscribe for weekly guides.