DEV Community

Alex Spinov
Alex Spinov

Posted on

tRPC Has a Free API: End-to-End TypeScript Type Safety Without Code Generation

tRPC lets you build fully type-safe APIs without schemas, code generation, or runtime validation on the client. Share types directly between your TypeScript backend and frontend — zero overhead.

Why tRPC?

  • Zero codegen — no OpenAPI, no GraphQL schema, no protobuf
  • End-to-end types — change server code, client types update instantly
  • Autocompletion — full IDE support for API calls
  • Tiny — ~2KB client, zero runtime overhead
  • Framework agnostic — Next.js, Express, Fastify, standalone

Server Setup

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

const t = initTRPC.create();

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

// Auth middleware
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { user: ctx.user } });
});
const protectedProcedure = t.procedure.use(isAuthed);

export const appRouter = router({
  // Public query
  hello: publicProcedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello, ${input.name}!` };
    }),

  // List users
  users: router({
    list: publicProcedure
      .input(z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.string().optional(),
      }))
      .query(async ({ input }) => {
        const users = await db.user.findMany({
          take: input.limit + 1,
          cursor: input.cursor ? { id: input.cursor } : undefined,
        });
        return {
          items: users.slice(0, input.limit),
          nextCursor: users[input.limit]?.id,
        };
      }),

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

    byId: publicProcedure
      .input(z.string())
      .query(async ({ input }) => {
        const user = await db.user.findUnique({ where: { id: input } });
        if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
        return user;
      }),
  }),
});

export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

Client (Full Autocompletion!)

// client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server/trpc';

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({ url: 'http://localhost:3000/api/trpc' }),
  ],
});

// Fully typed — IDE knows the exact return type!
const greeting = await trpc.hello.query({ name: 'World' });
console.log(greeting.greeting); // "Hello, World!"

// Type-safe user operations
const users = await trpc.users.list.query({ limit: 20 });
const user = await trpc.users.byId.query('user-id-123');
const newUser = await trpc.users.create.mutate({
  name: 'Alice',
  email: 'alice@example.com',
});
Enter fullscreen mode Exit fullscreen mode

React Integration

import { trpc } from '../utils/trpc';

function UserList() {
  const { data, isLoading } = trpc.users.list.useQuery({ limit: 10 });
  const createUser = trpc.users.create.useMutation();

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

  return (
    <div>
      {data?.items.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
      <button onClick={() => createUser.mutate({
        name: 'New User',
        email: 'new@example.com',
      })}>
        Add User
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Features

Feature Details
Type safety End-to-end, zero codegen
Validation Zod, Yup, Superstruct
Batching Automatic request batching
Subscriptions WebSocket support
Frameworks Next.js, Express, Fastify, standalone
React Query Built-in integration

Resources


Building TypeScript apps? Check my Apify actors or email spinov001@gmail.com.

Top comments (0)