Skip to main content

From REST to tRPC: The Type-Safe API Revolution

·PkgPulse Team

TL;DR

tRPC gives you full-stack TypeScript type safety with zero code generation — your API types are just your code. tRPC (~2M weekly downloads) became the standard for full-stack TypeScript apps because it eliminates the client/server type drift that causes 30%+ of production bugs. No schema files, no codegen, no OpenAPI spec — just TypeScript. The trade-off: tRPC only works when client and server are TypeScript. For public APIs or multi-language stacks, REST or GraphQL is still the answer.

Key Takeaways

  • tRPC: ~2M weekly downloads — end-to-end types, zero codegen, T3 stack default
  • Works only with TypeScript — client and server must both be TypeScript
  • Zero overhead at runtime — tRPC is a thin wrapper over HTTP fetch
  • Validation built-in — Zod input validation with type inference
  • GraphQL alternative — similar DX goals but no schema language required

The Problem tRPC Solves

// The REST type drift problem:

// Server (Node.js/TypeScript):
app.get('/api/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  res.json(user);
  // Return type: User | null
  // But the client doesn't know this...
});

// Client (React/TypeScript):
const response = await fetch('/api/users/123');
const user = await response.json();
// Type: any — you get nothing from TypeScript

// You must manually sync:
type User = { id: string; name: string; email: string }; // Duplicate!
const user: User = await response.json(); // Cast. Hope it matches.

// 3 months later, server adds "createdAt: Date"
// Client still types as User — no error, just a runtime bug
// This is type drift. tRPC eliminates it.

tRPC: End-to-End Types

// server/router.ts — define your API with TypeScript
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  // Query (GET)
  user: t.router({
    getById: t.procedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        const user = await db.user.findUnique({ where: { id: input.id } });
        if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
        return user;
        // Return type automatically inferred as User
      }),

    list: t.procedure
      .input(z.object({
        limit: z.number().min(1).max(100).default(20),
        cursor: z.string().optional(),
      }))
      .query(async ({ input }) => {
        const users = await db.user.findMany({
          take: input.limit + 1,
          cursor: input.cursor ? { id: input.cursor } : undefined,
        });
        const hasMore = users.length > input.limit;
        return {
          users: users.slice(0, input.limit),
          nextCursor: hasMore ? users[input.limit].id : null,
        };
      }),
  }),

  // Mutation (POST/PUT/DELETE)
  post: t.router({
    create: t.procedure
      .input(z.object({
        title: z.string().min(1).max(200),
        content: z.string(),
        published: z.boolean().default(false),
      }))
      .mutation(async ({ input, ctx }) => {
        return db.post.create({
          data: { ...input, authorId: ctx.userId },
        });
      }),

    delete: t.procedure
      .input(z.object({ id: z.string() }))
      .mutation(async ({ input, ctx }) => {
        const post = await db.post.findUnique({ where: { id: input.id } });
        if (post?.authorId !== ctx.userId) {
          throw new TRPCError({ code: 'FORBIDDEN' });
        }
        return db.post.delete({ where: { id: input.id } });
      }),
  }),
});

export type AppRouter = typeof appRouter;
// client/trpc.ts — setup
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router';  // Import TYPE only

export const trpc = createTRPCReact<AppRouter>();
// Client usage — full type safety, zero runtime type casting
function UserProfile({ userId }: { userId: string }) {
  // Type: { id: string; name: string; email: string; createdAt: Date } | undefined
  const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
  // ^ TypeScript knows EXACTLY what this returns — same type as server

  if (isLoading) return <Spinner />;
  if (!user) return <NotFound />;

  // user.name — autocomplete works. TypeScript error if you access non-existent field.
  return <div>{user.name} — {user.email}</div>;
}

function CreatePost() {
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => utils.post.list.invalidate(),
  });

  return (
    <button
      onClick={() => createPost.mutate({
        title: 'My Post',
        content: 'Hello world',
        // published: false  ← optional, has default
      })}
      disabled={createPost.isPending}
    >
      Create
    </button>
  );
}

tRPC Middleware (Authentication)

// Protected procedures via middleware
const t = initTRPC.context<Context>().create();

// Context setup (runs per request)
export const createContext = async (opts: CreateNextContextOptions) => {
  const session = await getServerSession(opts.req, opts.res, authOptions);
  return { session, db };
};

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

// Reusable middleware
const enforceAuth = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: { ...ctx, session: ctx.session },  // Session is non-null after this
  });
});

// Procedures
const publicProcedure = t.procedure;
const protectedProcedure = t.procedure.use(enforceAuth);

export const appRouter = t.router({
  health: publicProcedure.query(() => 'ok'),

  profile: protectedProcedure.query(({ ctx }) => {
    // ctx.session.user is guaranteed non-null here
    return db.user.findUnique({ where: { id: ctx.session.user.id } });
  }),
});

tRPC vs REST vs GraphQL

AspectRESTGraphQLtRPC
Type safetyManual / OpenAPI codegenSchema + codegenAutomatic (TypeScript)
Learning curveLowHighMedium
Public API✅ Excellent✅ Excellent❌ TS-only
Multi-language clients
Bundle size (client)~0 (fetch)~30KB (Apollo)~15KB
FlexibilityHighHighLower (TS stack required)
When to usePublic APIs, REST clientsComplex queries, publicTypeScript monorepo
Downloads (2026)N/A (protocol)~3M~2M

When tRPC Makes Sense

✅ Use tRPC when:
- Full TypeScript stack (Next.js + Node.js/Hono)
- You want type safety without codegen maintenance
- Monorepo where server and client share types
- Team is TypeScript-first and values DX

❌ Don't use tRPC when:
- API needs to be consumed by non-TypeScript clients
- You need a public API documentation (OpenAPI/Swagger)
- Multiple services with different tech stacks
- GraphQL's query flexibility is needed (nested resolvers, batching)
- Mobile clients (React Native can use it, but native iOS/Android can't)

tRPC + Zod: The Full-Stack Validation Pattern

// Shared schemas — import on both client and server
// packages/validators/src/user.ts (in a monorepo)
import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(1).max(50),
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user'),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;

// Server — use the shared schema in the procedure
import { createUserSchema } from '@myapp/validators';

export const userRouter = t.router({
  create: protectedProcedure
    .input(createUserSchema)  // Same Zod schema
    .mutation(({ input }) => {
      // input is fully typed as CreateUserInput
      return db.user.create({ data: input });
    }),
});

// Client — use the same schema for form validation
import { createUserSchema, CreateUserInput } from '@myapp/validators';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

function CreateUserForm() {
  const form = useForm<CreateUserInput>({
    resolver: zodResolver(createUserSchema),
  });
  // Form errors match server validation — single source of truth
}

Compare tRPC and GraphQL package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.