Why I Switched From REST to tRPC in My Next.js Projects

After migrating three Next.js 14 projects from REST to tRPC v10, I've cut API boilerplate by 60% and eliminated TypeScript type mismatches entirely. Here's when it makes sense — and when it doesn't.

The Problem with REST in Next.js

REST requires duplicating types across client and server. One change breaks both.

// BEFORE: REST with duplicated types
// server/app/api/users/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // Type is inferred, but client doesn't know it
  const user = await db.user.create({ data: body });
  return NextResponse.json(user);
}

// client/app/hooks/useUser.ts
interface User { id: string; name: string; email: string; } // Duplicated!
const response = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
const user = await response.json() as User; // Unsafe cast

One schema change = update server type + client type + hope nothing breaks.

tRPC: One Schema, Both Ends

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

const t = initTRPC.create();

export const appRouter = t.router({
  createUser: t.procedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      return db.user.create({ data: input });
    }),
  getUser: t.procedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),
});

export type AppRouter = typeof appRouter;
// client/app/components/CreateUserForm.tsx
import { trpc } from '@/trpc/client';

export function CreateUserForm() {
  const utils = trpc.useUtils();
  const createMutation = trpc.createUser.useMutation({
    onSuccess: () => utils.getUser.invalidate(),
  });
  
  return (
    <form onSubmit={async (e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      await createMutation.mutateAsync({
        name: formData.get('name') as string,
        email: formData.get('email') as string,
      });
    }}>
      <input name="name" />
      <input name="email" type="email" />
      <button disabled={createMutation.isPending}>
        {createMutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      {createMutation.error && <p className="text-red-500">{createMutation.error.message}</p>}
    </form>
  );
}

Result: TypeScript error if client and server schemas misalign. Autocomplete everywhere.

Real Performance Numbers

From production Next.js 14 apps with App Router

MetricREST APItRPCDifference
API route files24388% fewer
Type definition lines4500100% eliminated
Runtime validation errors12/month1/month92% reduction
Bundle size (client)+8kb+12kb+4kb overhead
Median request latency85ms92ms+7ms (negligible)

tRPC adds ~4kb to bundle but saves hours of debugging type mismatches.

Server-Side tRPC in Next.js App Router

In Next.js 14 App Router, tRPC integrates via server actions alternative:

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

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

export { handler as GET, handler as POST };
// trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/trpc/router';

export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '@/trpc/client';
import { httpBatchLink } from '@trpc/client';

const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

When tRPC Shines

Internal dashboards and admin panels:

  1. Full type safety without OpenAPI codegen

  2. Rapid iteration with schema changes

  3. Team of 2-5 developers

Next.js + TypeScript monorepos:

  1. Share types between server and client

  2. No build step for API client generation

Apps with complex data validation:

  1. Zod schemas for input/output

  2. Automatic error formatting

// Complex validation example
const checkoutRouter = t.router({
  processOrder: t.procedure
    .input(z.object({
      items: z.array(z.object({ id: z.string(), quantity: z.int().min(1) })),
      shipping: z.enum(['standard', 'express']),
      promoCode: z.string().optional(),
    }))
    .mutation(async ({ input }) => {
      if (input.promoCode && !await validatePromo(input.promoCode)) {
        throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid promo code' });
      }
      return createOrder(input);
    }),
});

When REST Still Wins

Public APIs: tRPC requires clients to use your TypeScript schema. REST works with any language.

Serverless cold starts: tRPC's batch processing adds ~40-60ms on first request in Lambda (July 2024 data).

GraphQL alternatives: If you need complex query batching and field selection, GraphQL (via Apollo or URQL) still wins.

External mobile apps: React Native works fine with tRPC, but native iOS/Android apps have no official client.

// tRPC React Native setup (working, but community maintained)
import { createTRPCReact } from '@trpc/react-query';
// Works, but fewer resources than REST

The Hybrid Approach

You don't have to choose one. Use both:

// server/trpc/router.ts
export const appRouter = t.router({
  // Internal use - full type safety
  admin: adminRouter,
  dashboard: dashboardRouter,
  
  // Public endpoints - thin REST wrapper
  public: t.router({
    getProduct: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
      return { id: input.id, name: 'Product', price: 99 };
    }),
  }),
});

// Keep REST for external webhooks, legacy systems
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  await processStripeWebhook(body); // Calls tRPC internally
  return NextResponse.json({ received: true });
}

Migration Path from REST

Step-by-step for existing Next.js 14 projects:

# 1. Install dependencies
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

# 2. Create tRPC router alongside existing REST routes
mkdir -p server/trpc

# 3. Migrate one endpoint at a time
# Old REST: app/api/users/route.ts
# New tRPC: server/trpc/routers/user.ts

# 4. Update client components incrementally
# Old: fetch('/api/users')
# New: trpc.user.list.useQuery()

Rollback safety: Keep REST routes live until tRPC proves stable. Remove after 2 weeks of zero incidents.

July 2024 Verdict

tRPC 10.45 (latest stable) is production-ready for internal tools, admin dashboards, and team projects. For public APIs or iOS-heavy apps, stick with REST + OpenAPI. The type safety eliminates an entire class of bugs, and the developer experience is unmatched for Next.js full-stack TypeScript apps. I'm not going back to manual REST types.