Skip to content
· 9 min read · 0 views

Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas

Learn how tRPC eliminates the need for API schemas by leveraging TypeScript's type system. Build end-to-end type-safe APIs with automatic client generation.

// table of contents (30 sections)

Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas

If you’ve ever built a full-stack TypeScript application, you’ve probably experienced the “type drift” problem: your backend types and frontend types slowly diverge, leading to runtime bugs that TypeScript couldn’t catch. tRPC solves this elegantly by sharing types directly between client and server—no code generation, no schemas, just pure TypeScript.

For the broader context on modern frontend stacks, see my guide on the modern frontend stack of 2026.


The Problem tRPC Solves

Traditional API Development

Backend                    Frontend
├── Define types           ├── Fetch API
├── Write handlers         ├── Guess response types
├── Create OpenAPI spec    ├── Generate types from spec
└── Deploy                 └── Pray they match

The Pain Points:

  1. Schema maintenance: OpenAPI/Swagger specs need constant updates
  2. Code generation lag: Generated types are always one step behind
  3. Runtime mismatches: Types compile but API returns unexpected data
  4. Duplication: Same types defined in multiple places

The tRPC Approach

Backend + Frontend (Shared TypeScript Project)
├── Define router with types
├── Export type inference
├── Client auto-completes everything
└── Zero runtime surprises

How tRPC Works

1. Define Your Router (Backend)

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

const t = initTRPC.create();

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

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

export const userRouter = router({
  // Query: Get user by ID
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ 
        where: { id: input.id } 
      });
      return user;
    }),

  // Mutation: Create user
  create: publicProcedure
    .input(z.object({ 
      name: z.string(),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      const user = await db.user.create({ data: input });
      return user;
    }),

  // Subscription: Real-time updates
  onUpdate: publicProcedure
    .subscription(() => {
      return observable<User>((emit) => {
        const subscription = db.user.onUpdate((user) => {
          emit.next(user);
        });
        return () => subscription.unsubscribe();
      });
    }),
});

export type UserRouter = typeof userRouter;

2. Create the API Server

// server/index.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { userRouter } from './routers/user';

const appRouter = userRouter;

export type AppRouter = typeof appRouter;

createHTTPServer({
  router: appRouter,
}).listen(3000);

3. Consume from Frontend

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

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

// Full autocomplete and type safety!
const user = await client.getById.query({ id: '123' });
//    ^? { id: string; name: string; email: string; } | null

const newUser = await client.create.mutate({ 
  name: 'Abdu', 
  email: 'abdu@example.com' 
});
// Full type checking on input!

Key Features in 2026

Superjson for Rich Types

tRPC now uses Superjson by default, enabling:

// Dates, Maps, Sets, and more work seamlessly
const router = t.router({
  getMetrics: publicProcedure.query(() => ({
    timestamp: new Date(),
    metadata: new Map([['version', '2.0']]),
    tags: new Set(['production', 'stable']),
  })),
});

// Client receives actual Date/Map/Set objects, not JSON strings!
const metrics = await client.getMetrics.query();
console.log(metrics.timestamp instanceof Date); // true

Automatic Request Batching

tRPC batches multiple requests into a single HTTP call:

// These three calls become ONE HTTP request
const [user, posts, comments] = await Promise.all([
  client.user.getById.query({ id: '1' }),
  client.posts.list.query({ userId: '1' }),
  client.comments.list.query({ userId: '1' }),
]);

Built-in Error Handling

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

export const protectedProcedure = t.procedure
  .use(async ({ ctx, next }) => {
    if (!ctx.user) {
      throw new TRPCError({
        code: 'UNAUTHORIZED',
        message: 'You must be logged in',
      });
    }
    return next();
  });

// client gets typed errors
try {
  await client.admin.deleteUser.mutate({ id: '1' });
} catch (error) {
  if (error instanceof TRPCClientError) {
    // Full type info on error
    console.log(error.data.code); // 'UNAUTHORIZED'
  }
}

tRPC vs Alternatives

FeaturetRPCGraphQLREST + OpenAPI
Type SafetyAutomaticCodegenCodegen
Schema RequiredNoYesYes
Learning CurveLowMediumLow
Bundle SizeSmallLargeSmall
Browser SupportFullFullFull
Real-timeBuilt-inSubscriptionsSeparate impl

When to Use tRPC

Best For:

  • Full-stack TypeScript projects
  • Monorepos with shared types
  • Teams that want fast iteration
  • Real-time features needed

Not Ideal For:

  • Public APIs (external consumers)
  • Non-TypeScript clients
  • Microservices with different languages

Real-World Example: Building a Task Manager

Backend Setup

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

export const taskRouter = router({
  list: publicProcedure
    .input(z.object({
      status: z.enum(['todo', 'in_progress', 'done']).optional(),
      limit: z.number().min(1).max(100).default(20),
      cursor: z.string().optional(),
    }))
    .query(async ({ input }) => {
      const tasks = await db.task.findMany({
        where: input.status ? { status: input.status } : undefined,
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      });

      let nextCursor: string | undefined;
      if (tasks.length > input.limit) {
        const nextItem = tasks.pop();
        nextCursor = nextItem!.id;
      }

      return { tasks, nextCursor };
    }),

  create: publicProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      description: z.string().optional(),
      priority: z.enum(['low', 'medium', 'high']).default('medium'),
    }))
    .mutation(async ({ input }) => {
      return db.task.create({
        data: {
          ...input,
          status: 'todo',
        },
      });
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      data: z.object({
        title: z.string().min(1).max(200).optional(),
        status: z.enum(['todo', 'in_progress', 'done']).optional(),
        priority: z.enum(['low', 'medium', 'high']).optional(),
      }),
    }))
    .mutation(async ({ input }) => {
      return db.task.update({
        where: { id: input.id },
        data: input.data,
      });
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      await db.task.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

export type TaskRouter = typeof taskRouter;

Frontend Usage with React

// client/App.tsx
import { trpc } from './trpc';

function TaskList() {
  const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>();

  // Queries auto-refetch and cache intelligently
  const { data, isLoading, fetchNextPage, hasNextPage } = 
    trpc.task.list.useInfiniteQuery(
      { status, limit: 20 },
      { getNextPageParam: (lastPage) => lastPage.nextCursor }
    );

  const createMutation = trpc.task.create.useMutation({
    onSuccess: () => {
      // Auto-invalidate and refetch
      utils.task.list.invalidate();
    },
  });

  const updateMutation = trpc.task.update.useMutation();

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

  return (
    <div>
      <select onChange={(e) => setStatus(e.target.value as any)}>
        <option value="">All</option>
        <option value="todo">To Do</option>
        <option value="in_progress">In Progress</option>
        <option value="done">Done</option>
      </select>

      {data?.pages.flatMap((page) => page.tasks).map((task) => (
        <div key={task.id}>
          <h3>{task.title}</h3>
          <select
            value={task.status}
            onChange={(e) => updateMutation.mutate({
              id: task.id,
              data: { status: e.target.value as any },
            })}
          >
            <option value="todo">To Do</option>
            <option value="in_progress">In Progress</option>
            <option value="done">Done</option>
          </select>
        </div>
      ))}

      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load More</button>
      )}
    </div>
  );
}

Next.js App Router

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

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

export { handler as GET, handler as POST };

Astro Integration

tRPC works great with Astro for building dynamic features. See my post on the modern frontend stack for how Astro + React combine beautifully.

// src/pages/api/trpc/[...trpc].ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';

export const ALL = ({ request }: APIContext) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req: request,
    router: appRouter,
  });

Performance Tips

1. Use Batching Wisely

// Bad: Sequential requests
const user = await client.user.getById.query({ id: '1' });
const posts = await client.post.list.query({ userId: '1' });

// Good: Parallel requests (auto-batched)
const [user, posts] = await Promise.all([
  client.user.getById.query({ id: '1' }),
  client.post.list.query({ userId: '1' }),
]);

2. Enable Caching

const utils = trpc.useUtils();

// Cache the result
await utils.user.getById.prefetch({ id: '1' });

// Later, this returns instantly
const user = await client.user.getById.query({ id: '1' });

3. Optimize with Middleware

// Add caching middleware for expensive queries
import { initTRPC } from '@trpc/server';
import { cache } from 'react';

const t = initTRPC.create();

export const cachedProcedure = t.procedure
  .use(async ({ path, next }) => {
    // Cache GET-like queries
    if (path.includes('.query')) {
      return cache(() => next())();
    }
    return next();
  });

Security Best Practices

Rate Limiting

For API protection, see my guide on rate limiting strategies for APIs.

import { Ratelimit } from '@unkey/ratelimit';

const limiter = new Ratelimit({
  redis: redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

export const rateLimitedProcedure = publicProcedure
  .use(async ({ ctx, next }) => {
    const { success } = await limiter.limit(ctx.ip);
    if (!success) throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
    return next();
  });

Input Validation

Zod schemas provide runtime validation:

const createPostSchema = z.object({
  title: z.string()
    .min(5, 'Title must be at least 5 characters')
    .max(200, 'Title too long'),
  content: z.string()
    .min(100, 'Content too short')
    .refine(
      (val) => !containsSpam(val),
      'Content contains forbidden words'
    ),
  tags: z.array(z.string()).max(5, 'Maximum 5 tags'),
});

Common Pitfalls

1. Circular Dependencies

// Bad: Circular type reference
type User = {
  posts: Post[];
};
type Post = {
  author: User;  // Circular!
};

// Good: Use type references
type User = {
  posts: Post[];
};
type Post = {
  authorId: string;
};

2. Over-fetching

// Bad: Returns entire user object
getUser: publicProcedure.query(() => db.user.findMany()),

// Good: Select only needed fields
getUser: publicProcedure
  .output(z.array(z.object({
    id: z.string(),
    name: z.string(),
  })))
  .query(() => db.user.findMany({
    select: { id: true, name: true },
  })),

Conclusion

tRPC represents a paradigm shift in API development for TypeScript teams. By eliminating the gap between frontend and backend types, it removes an entire class of bugs while dramatically improving developer experience.

Key Benefits:

  • Zero-config type safety: Types flow from server to client automatically
  • No schema maintenance: Your TypeScript types ARE the schema
  • Excellent DX: Autocomplete for everything, errors at compile time
  • Production-ready: Used by major companies at scale

When to Adopt:

  • If you’re building a TypeScript monorepo
  • If your frontend and backend teams are the same people
  • If you want to move fast without sacrificing type safety

Next Steps:


Are you using tRPC in your projects? Connect with me on Twitter to discuss your experience!

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion