Back to Blog

Why We Switched From REST to tRPC (and When You Should Too)

Why We Switched From REST to tRPC (and When You Should Too)

For years, every API we built was REST. Define routes, write controllers, document the request and response shapes, then write the client code that calls those routes and hopes the types match. It worked. It shipped products. But it also produced a specific category of bug that we got tired of fixing: the type mismatch between client and server.

A field gets renamed on the backend. The frontend still sends the old name. No error at compile time — just a 400 response in production. Or worse, a silent data loss where the server ignores the unknown field and saves incomplete data.

We switched to tRPC in mid-2024 and have used it on every new full-stack TypeScript project since, including parts of LancerSpace and MindHyv. This post explains what tRPC gives you, what it costs, and when REST is still the better choice.

What tRPC Actually Is

tRPC is a framework for building type-safe APIs in TypeScript. The server defines procedures (think endpoints), and the client calls those procedures with full type inference — input types, output types, error types, all inferred from the server code without any code generation step.

No OpenAPI spec. No GraphQL schema. No codegen. You write the server code, and the client knows exactly what to send and what it will get back.

Here is a minimal example.

Server:

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

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

export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user: ctx.session.user } });
});
// server/routers/projects.ts
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc';

export const projectsRouter = router({
  list: protectedProcedure.query(async ({ ctx }) => {
    return ctx.db
      .select()
      .from(projects)
      .where(eq(projects.userId, ctx.user.id));
  }),

  create: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        description: z.string().optional(),
        clientId: z.string().uuid().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const [project] = await ctx.db
        .insert(projects)
        .values({
          name: input.name,
          description: input.description,
          clientId: input.clientId,
          userId: ctx.user.id,
        })
        .returning();

      return project;
    }),

  getById: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const project = await ctx.db
        .select()
        .from(projects)
        .where(
          and(
            eq(projects.id, input.id),
            eq(projects.userId, ctx.user.id)
          )
        )
        .limit(1)
        .then((rows) => rows[0]);

      if (!project) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }

      return project;
    }),
});

Client:

// In a Svelte component or page
const projects = await trpc.projects.list.query();
// TypeScript knows: projects is Array<{ id: string; name: string; description: string | null; ... }>

const newProject = await trpc.projects.create.mutate({
  name: 'New Website',
  description: 'Landing page redesign',
});
// TypeScript knows: newProject is { id: string; name: string; description: string | null; ... }

Notice what is not here: no type definitions for the API contract, no fetch calls with manual typing, no request/response interfaces that need to stay in sync with the server. The client types are inferred directly from the server code through TypeScript’s type system.

Why End-to-End Type Safety Matters

The productivity gain from tRPC is not about catching type errors — although it does catch them. The real gain is in the development workflow.

Autocomplete everywhere. When you type trpc.projects., your editor shows every available procedure. When you type .create.mutate({, it shows every valid input field with their types. This eliminates the need to check API documentation or read server code while building the frontend.

Refactoring propagates instantly. Rename a field on the server and every client call that uses it shows a TypeScript error. Add a required input field and every mutation that is missing it lights up. This is the bug category that REST cannot catch and that used to cost us hours of debugging.

No API documentation to maintain. The types are the documentation. In a full-stack TypeScript project, this eliminates an entire category of work that adds no user value.

On LancerSpace, we have over 80 tRPC procedures covering clients, projects, invoices, proposals, time tracking, and settings. If any of these were REST endpoints, we would need either an OpenAPI spec (with a codegen step to generate client types) or manually maintained TypeScript interfaces on both sides. With tRPC, the contract is the code.

Close-up of TypeScript code with type annotations ensuring safe data handling

Setting Up tRPC With SvelteKit

Most of our projects use SvelteKit. Here is how we integrate tRPC:

// src/lib/server/trpc/root.ts
import { router } from './trpc';
import { projectsRouter } from './routers/projects';
import { clientsRouter } from './routers/clients';
import { invoicesRouter } from './routers/invoices';
import { settingsRouter } from './routers/settings';

export const appRouter = router({
  projects: projectsRouter,
  clients: clientsRouter,
  invoices: invoicesRouter,
  settings: settingsRouter,
});

export type AppRouter = typeof appRouter;
// src/lib/trpc.ts (client)
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '$lib/server/trpc/root';

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: '/api/trpc',
    }),
  ],
});
// src/routes/api/trpc/[...path]/+server.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '$lib/server/trpc/root';
import { createContext } from '$lib/server/trpc/context';
import type { RequestHandler } from './$types';

const handler: RequestHandler = async (event) => {
  return fetchRequestHandler({
    endpoint: '/api/trpc',
    req: event.request,
    router: appRouter,
    createContext: () => createContext(event),
  });
};

export const GET = handler;
export const POST = handler;

The AppRouter type is the bridge. It is exported from the server and imported by the client as a type-only import — no server code leaks into the client bundle.

Input Validation With Zod

tRPC pairs naturally with Zod for input validation. Every procedure’s .input() call defines both the runtime validation and the TypeScript type:

const updateInvoice = protectedProcedure
  .input(
    z.object({
      id: z.string().uuid(),
      status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']),
      lineItems: z.array(
        z.object({
          description: z.string().min(1),
          quantity: z.number().positive(),
          unitPrice: z.number().nonnegative(),
        })
      ).min(1),
      dueDate: z.string().datetime(),
      notes: z.string().max(1000).optional(),
    })
  )
  .mutation(async ({ ctx, input }) => {
    // input is fully typed and validated
    // TypeScript knows input.lineItems is Array<{ description: string; quantity: number; unitPrice: number }>
  });

On the client side, if you try to pass an invalid status value or forget a required field, TypeScript catches it at compile time. If someone sends a malformed request directly to the API (bypassing the client), Zod catches it at runtime and returns a structured validation error.

This dual-layer validation is something we had to build manually with REST. We would define a Zod schema, validate the request body, and then separately define TypeScript interfaces for the client. With tRPC, one schema definition covers both. We discussed the importance of validation patterns in our post on API design patterns.

Client and server communication illustrated through connected network devices

Error Handling

tRPC has a built-in error system with typed error codes:

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

// Server
const deleteProject = protectedProcedure
  .input(z.object({ id: z.string().uuid() }))
  .mutation(async ({ ctx, input }) => {
    const project = await ctx.db
      .select()
      .from(projects)
      .where(eq(projects.id, input.id))
      .limit(1)
      .then((rows) => rows[0]);

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

    if (project.userId !== ctx.user.id) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'You do not own this project',
      });
    }

    await ctx.db.delete(projects).where(eq(projects.id, input.id));
    return { success: true };
  });

// Client
try {
  await trpc.projects.delete.mutate({ id: projectId });
} catch (error) {
  if (error instanceof TRPCClientError) {
    if (error.data?.code === 'NOT_FOUND') {
      // Handle not found
    }
    if (error.data?.code === 'FORBIDDEN') {
      // Handle forbidden
    }
  }
}

The error codes map to HTTP status codes when sent over the wire, so middleware, logging, and monitoring tools work as expected.

When REST Is Still Better

tRPC is not a universal replacement for REST. There are clear cases where REST is the right choice:

Public APIs. If external developers will consume your API, REST with OpenAPI documentation is the industry standard. tRPC requires both client and server to be TypeScript — it does not work for mobile apps written in Swift or Kotlin, Python scripts, or third-party integrations. For VincelIO, the internal dashboard uses tRPC, but the public API for brand integrations is REST.

Multi-language backends. tRPC is a TypeScript-only tool. If your backend is Python, Go, or Java, it is not an option.

Simple CRUD with no frontend. If you are building an API that will be consumed by multiple clients (web, mobile, third-party), REST gives you a universal interface that any HTTP client can use.

When you need HTTP caching. REST’s GET requests play nicely with CDN caching, browser caching, and HTTP cache headers. tRPC uses POST for mutations and can use GET for queries, but the URL structure does not map as cleanly to traditional HTTP caching strategies.

Existing REST infrastructure. If your team has years of REST conventions, middleware, and tooling, the migration cost to tRPC may not be worth the type safety benefit. Consider using OpenAPI with codegen (tools like openapi-typescript or orval) as a lighter-weight path to type-safe API calls.

API request and response flow visualized on a developer workstation

Migration Tips

If you are moving from REST to tRPC, here is what we learned:

Migrate route by route. You do not need to switch everything at once. Run REST and tRPC side by side, moving one endpoint at a time. tRPC runs on a single catch-all route (/api/trpc/[...path]), so it does not conflict with existing REST routes.

Start with the most-changed routes. The biggest benefit of tRPC is during active development when types are changing frequently. Move the routes that are still being iterated on first.

Keep your Zod schemas. If you already use Zod for REST request validation, those schemas plug directly into tRPC’s .input() method.

Plan for the public API boundary. If you might need a public API later, design your tRPC procedures so the business logic is in a shared service layer, not in the procedure itself. That way you can expose the same logic through a REST endpoint without duplication.

Our Current Setup

For new full-stack TypeScript projects, our standard stack is:

tRPC sits at the center of this stack, connecting the SvelteKit frontend to the Drizzle-powered backend with full type inference at every layer. Change a database column in the Drizzle schema, and the tRPC procedure return type updates, and the frontend component using that data shows a type error if it references the old shape. That feedback loop — from database to UI — is what makes full-stack TypeScript worth the investment.

If you are building a TypeScript application and want help setting up a type-safe API layer, reach out at hello@threshline.com.