← Back to DevBytes

tRPC from Beginner to Expert: A Learning Path

Introduction to tRPC: What It Is and Why It Matters

tRPC (TypeScript Remote Procedure Call) is a modern library that enables you to build end-to-end type-safe APIs without code generation, schemas, or any extra boilerplate. Created by Alex Johansson, tRPC has rapidly gained traction in the TypeScript ecosystem as a way to share types directly between your server and client code.

In traditional API development, you typically define your backend routes and then manually mirror those types on the frontend. This leads to drift — your frontend types can become out of sync with your backend, causing runtime errors that TypeScript cannot catch at compile time. tRPC eliminates this problem by allowing your client to import type definitions directly from your server implementation.

Why tRPC Matters

Setting Up Your First tRPC Project

Let's start by building a simple tRPC server and client. We'll use Node.js with Express for the server and a vanilla TypeScript client for demonstration.

Installing Dependencies

First, initialize a new project and install the required packages:

npm init -y
npm install @trpc/server @trpc/client express cors
npm install -D typescript @types/node @types/express @types/cors tsx
npx tsc --init

Creating the Router

The router is the heart of any tRPC application. It defines procedures that clients can call. A procedure can be a query (read), mutation (write), or subscription (real-time stream).

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

const t = initTRPC.create();

export const router = t.router({
  greeting: t.procedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { message: `Hello, ${input.name}!` };
    }),

  createUser: t.procedure
    .input(z.object({
      name: z.string().min(2),
      email: z.string().email(),
    }))
    .mutation(({ input }) => {
      // In a real app, save to database
      const user = { id: Date.now(), ...input };
      return user;
    }),
});

export type AppRouter = typeof router;

Notice the export type AppRouter = typeof router; line. This is the magic that enables type safety — the client imports this type to know exactly what procedures exist and what their input/output shapes are.

Wiring Up the Server

// server/index.ts
import express from 'express';
import cors from 'cors';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { router } from './router';

const app = express();

app.use(cors());
app.use(
  '/trpc',
  createExpressMiddleware({
    router,
  })
);

app.listen(3000, () => {
  console.log('tRPC server running on http://localhost:3000');
});

Creating the Client

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

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

async function main() {
  // Fully type-safe call with autocompletion
  const greeting = await client.greeting.query({ name: 'Developer' });
  console.log(greeting.message);

  const user = await client.createUser.mutate({
    name: 'Alice',
    email: 'alice@example.com',
  });
  console.log('Created user:', user);
}

main();

If you try to pass an invalid email or omit the name field, TypeScript will throw an error before you even run the code. This is the power of tRPC.

Understanding Procedures: Queries, Mutations, and Subscriptions

tRPC procedures map closely to the concepts you already know from REST and GraphQL:

Adding a Subscription

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

const t = initTRPC.create();

export const router = t.router({
  // ... existing procedures

  countdown: t.procedure
    .input(z.object({ from: z.number() }))
    .subscription(({ input }) => {
      return observable<number>((emit) => {
        let count = input.from;
        const interval = setInterval(() => {
          emit.next(count);
          count--;
          if (count < 0) {
            clearInterval(interval);
            emit.complete();
          }
        }, 1000);

        return () => clearInterval(interval);
      });
    }),
});

export type AppRouter = typeof router;

Input Validation with Zod

tRPC integrates seamlessly with Zod, a TypeScript-first schema validation library. Every procedure can define an input schema that validates incoming data at runtime while also providing static types at compile time.

import { z } from 'zod';

const updateUser = t.procedure
  .input(z.object({
    id: z.number(),
    name: z.string().min(2).max(50).optional(),
    email: z.string().email().optional(),
    role: z.enum(['admin', 'user', 'guest']).optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
  }))
  .mutation(({ input }) => {
    // input is fully typed and validated
    return { success: true, updated: input };
  });

If validation fails, tRPC automatically returns a structured error to the client with details about which fields failed. You can also use other validation libraries like Yup or Valibot if you prefer.

Context and Middleware

Creating Context

Context allows you to share data across all procedures, such as the authenticated user, database connections, or request metadata. You define context creation logic when setting up your server adapter.

// server/context.ts
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';

export interface Context {
  user: { id: string; name: string } | null;
  db: DbClient;
}

export async function createContext(
  opts: CreateExpressContextOptions
): Promise<Context> {
  const token = opts.req.headers.authorization?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;

  return {
    user,
    db: dbClient,
  };
}
// server/index.ts (updated)
app.use(
  '/trpc',
  createExpressMiddleware({
    router,
    createContext,
  })
);

Using Context in Procedures

// server/router.ts
const t = initTRPC.context<Context>().create();

export const router = t.router({
  me: t.procedure.query(({ ctx }) => {
    if (!ctx.user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }
    return ctx.user;
  }),

  myPosts: t.procedure.query(({ ctx }) => {
    if (!ctx.user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }
    return ctx.db.posts.findMany({ where: { authorId: ctx.user.id } });
  }),
});

Middleware for Authentication and Logging

Middleware lets you intercept procedure calls for cross-cutting concerns like authentication, logging, rate limiting, or performance monitoring.

// server/middleware.ts
const t = initTRPC.context<Context>().create();

// Logging middleware
const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(`${type} ${path} took ${duration}ms`);
  return result;
});

// Auth middleware
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      user: ctx.user, // now non-null in downstream procedures
    },
  });
});

// Public procedures with logging
export const publicProcedure = t.procedure.use(loggingMiddleware);

// Protected procedures
export const protectedProcedure = t.procedure.use(loggingMiddleware).use(isAuthed);

export const router = t.router({
  publicData: publicProcedure.query(() => 'open to everyone'),

  secretData: protectedProcedure.query(({ ctx }) => {
    return `Hello ${ctx.user.name}, this is secret!`;
  }),
});

Structuring Large Applications with Merging Routers

As your application grows, putting all procedures in a single router becomes unwieldy. tRPC provides mergeRouters to combine multiple routers into one, allowing you to organize procedures by domain.

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

export const usersRouter = router({
  list: publicProcedure.query(({ ctx }) => {
    return ctx.db.users.findMany();
  }),

  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input, ctx }) => {
      return ctx.db.users.findById(input.id);
    }),

  create: publicProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(({ input, ctx }) => {
      return ctx.db.users.create(input);
    }),
});
// server/routers/posts.ts
import { protectedProcedure, router } from '../trpc';
import { z } from 'zod';

export const postsRouter = router({
  list: protectedProcedure.query(({ ctx }) => {
    return ctx.db.posts.findMany({ where: { authorId: ctx.user.id } });
  }),

  create: protectedProcedure
    .input(z.object({ title: z.string(), body: z.string() }))
    .mutation(({ input, ctx }) => {
      return ctx.db.posts.create({ ...input, authorId: ctx.user.id });
    }),
});
// server/index.ts
import { mergeRouters } from '@trpc/server';
import { usersRouter } from './routers/users';
import { postsRouter } from './routers/posts';

const appRouter = mergeRouters({
  users: usersRouter,
  posts: postsRouter,
});

export type AppRouter = typeof appRouter;

On the client side, you now access procedures with a namespace prefix:

// Client usage
const users = await client.users.list.query();
const post = await client.posts.create.mutate({
  title: 'My First Post',
  body: 'Hello world!',
});

Integrating tRPC with Next.js

One of the most popular use cases for tRPC is with Next.js, particularly the App Router. The @trpc/next package and React Query integration provide powerful data fetching hooks with caching, refetching, and optimistic updates.

Setting Up the tRPC Client in Next.js

// lib/trpc.ts
import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app';

function getBaseUrl() {
  if (typeof window !== 'undefined') return '';
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return 'http://localhost:3000';
}

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    };
  },
  ssr: true,
});

Using tRPC Hooks in Components

// components/UserList.tsx
import { trpc } from '@/lib/trpc';

export function UserList() {
  const { data: users, isLoading, error } = trpc.users.list.useQuery();

  const createUser = trpc.users.create.useMutation({
    onSuccess: () => {
      utils.users.list.invalidate();
    },
  });

  const utils = trpc.useUtils();

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

  return (
    <div>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name} - {user.email}</li>
        ))}
      </ul>
      <button
        onClick={() =>
          createUser.mutate({
            name: 'New User',
            email: 'new@example.com',
          })
        }
      >
        Add User
      </button>
    </div>
  );
}

Optimistic Updates

const updatePost = trpc.posts.update.useMutation({
  async onMutate(newPost) {
    // Cancel outgoing refetches
    await utils.posts.list.cancel();

    // Snapshot the previous value
    const previousPosts = utils.posts.list.getData();

    // Optimistically update the cache
    utils.posts.list.setData(undefined, (old) =>
      old?.map((p) => (p.id === newPost.id ? { ...p, ...newPost } : p))
    );

    return { previousPosts };
  },
  onError(err, newPost, context) {
    // Roll back on error
    if (context?.previousPosts) {
      utils.posts.list.setData(undefined, context.previousPosts);
    }
  },
  onSettled() {
    utils.posts.list.invalidate();
  },
});

Error Handling

tRPC has a built-in error handling system with predefined error codes. You can throw TRPCError in any procedure, and the client receives a structured error object.

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

const getUser = t.procedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input, ctx }) => {
    const user = await ctx.db.users.findById(input.id);

    if (!user) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: `User with id ${input.id} not found`,
      });
    }

    if (user.private && ctx.user?.id !== user.id) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'You do not have access to this profile',
      });
    }

    return user;
  });

On the client side, you can handle errors using the error object returned by hooks:

const { data, error } = trpc.users.getById.useQuery({ id: '123' });

if (error) {
  if (error.data?.code === 'NOT_FOUND') {
    return <p>User not found</p>;
  }
  if (error.data?.code === 'FORBIDDEN') {
    return <p>Access denied</p>;
  }
  return <p>An error occurred: {error.message}</p>;
}

Custom Error Formatting

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error, ctx }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        timestamp: new Date().toISOString(),
        path: shape.data.path,
        userId: ctx?.user?.id ?? null,
      },
    };
  },
});

Transforming Input and Output

Sometimes you need to transform data between what the client sends and what the server processes, or between what the server returns and what the client receives. tRPC supports transformer middleware for this, commonly used with Superjson to handle Date objects, Maps, Sets, and other non-JSON-serializable types.

npm install superjson
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';

const t = initTRPC.create({
  transformer: superjson,
});

export const router = t.router({
  getTimestamp: t.procedure.query(() => {
    return { now: new Date(), numbers: new Set([1, 2, 3]) };
  }),
});
// client/index.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from './server/router';

const client = createTRPCProxyClient<AppRouter>({
  transformer: superjson,
  links: [
    httpBatchLink({ url: 'http://localhost:3000/trpc' }),
  ],
});

const result = await client.getTimestamp.query();
console.log(result.now instanceof Date); // true
console.log(result.numbers instanceof Set); // true

Best Practices for Production tRPC Applications

1. Organize by Feature, Not by Type

Group your routers by domain (users, posts, comments) rather than by procedure type (all queries, all mutations). This keeps related logic together and makes the codebase easier to navigate.

2. Use a Centralized tRPC Instance

Create a single trpc.ts file that initializes your tRPC instance with context, middleware, and error formatting. Export t, router, publicProcedure, and protectedProcedure from this file so all routers use the same configuration.

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import type { Context } from './context';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return shape;
  },
});

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

export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
export const mergeRouters = t.mergeRouters;

3. Validate Everything with Zod

Never trust client input. Always define Zod schemas for every procedure that accepts input, even for internal procedures. This provides a consistent validation layer and generates accurate TypeScript types.

4. Use Batch Links for Performance

The httpBatchLink automatically batches multiple procedure calls into a single HTTP request within a small time window. This significantly reduces network overhead when your client makes multiple calls in quick succession.

5. Implement Rate Limiting

Use middleware to implement rate limiting on sensitive procedures, especially mutations. Libraries like rate-limiter-flexible integrate well with tRPC middleware.

import RateLimit from 'express-rate-limit';

const rateLimiter = new RateLimit({
  windowMs: 60 * 1000,
  max: 30,
});

const rateLimitedProcedure = t.procedure.use(
  t.middleware(async ({ ctx, next }) => {
    // Implement rate limiting logic here
    return next();
  })
);

6. Leverage SSR for Initial Data

In Next.js, enable server-side rendering with tRPC to prefetch data on the server. This improves perceived performance and SEO by sending fully rendered HTML to the client.

7. Type Your Context Strictly

Always define a strict interface for your context. This prevents accidental type widening and ensures that middleware that adds properties to context (like authentication) properly narrows types for downstream procedures.

8. Use Meta for Procedure Metadata

tRPC supports procedure metadata, which you can use for declarative authorization, caching strategies, or documentation.

const t = initTRPC.context<Context>().meta<{ requiresAuth?: boolean }>().create();

const isAuthed = t.middleware(({ meta, ctx, next }) => {
  if (meta?.requiresAuth && !ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next();
});

export const protectedProcedure = t.procedure
  .use(isAuthed)
  .meta({ requiresAuth: true });

Testing tRPC Procedures

Testing tRPC procedures is straightforward because you can call them directly without spinning up an HTTP server. Use the createCaller function to invoke procedures in unit tests.

// tests/users.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { usersRouter } from '../server/routers/users';

describe('users router', () => {
  const caller = usersRouter.createCaller({
    user: { id: '1', name: 'Test User' },
    db: mockDb,
  });

  it('should create a user', async () => {
    const user = await caller.create({
      name: 'Alice',
      email: 'alice@example.com',
    });

    expect(user.name).toBe('Alice');
    expect(user.email).toBe('alice@example.com');
    expect(user.id).toBeDefined();
  });

  it('should reject invalid email', async () => {
    await expect(
      caller.create({ name: 'Bob', email: 'not-an-email' })
    ).rejects.toThrow();
  });
});

Deploying tRPC Applications

tRPC applications deploy like any standard Node.js application. Since tRPC runs over HTTP, it works with any hosting provider: Vercel, Railway, Fly.io, AWS, Docker containers, or traditional VPS setups.

Key deployment considerations:

// Client with timeout configuration
import { httpBatchLink } from '@trpc/client';

const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: '/api/trpc',
      timeout: 10000,
      headers() {
        return {
          Authorization: `Bearer ${getToken()}`,
        };
      },
    }),
  ],
});

Conclusion

tRPC represents a paradigm shift in how TypeScript developers build APIs. By eliminating the gap between server and client types, it removes an entire class of bugs while dramatically improving developer experience. From simple query procedures to complex nested routers with middleware, context, and real-time subscriptions, tRPC scales from small projects to large production applications. By following the best practices outlined in this tutorial — organizing routers by domain, validating all inputs with Zod, implementing proper authentication middleware, and leveraging batch links for performance — you can build robust, type-safe APIs that are a joy to maintain. Whether you are building a new project from scratch or considering migrating from REST or GraphQL, tRPC offers a compelling solution that leverages the full power of TypeScript's type system to keep your entire stack in sync.

— Ad —

Google AdSense will appear here after approval

← Back to all articles