正在加载,请稍候…

tRPC: End-to-End Type Safety for Full-Stack TypeScript Apps

Build fully type-safe APIs with tRPC — router setup, procedures, input validation with Zod, middleware, React Query integration, and Next.js App Router support.

What Makes tRPC Special

tRPC eliminates the need for code generation. Your TypeScript types flow from server to client automatically — no OpenAPI, no GraphQL schema, just TypeScript.

Setup

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

Server Setup

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

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

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

// Protected 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 } });
});

Context

// server/context.ts
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';

export async function createContext(opts: CreateNextContextOptions) {
  const session = await getServerSession(opts.req, opts.res, authOptions);
  return { prisma, session };
}

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

Routers and Procedures

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

export const usersRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true, createdAt: true },
      });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
      return user;
    }),
  
  list: publicProcedure
    .input(z.object({
      page: z.number().min(1).default(1),
      perPage: z.number().min(1).max(100).default(20),
      search: z.string().optional(),
    }))
    .query(async ({ input, ctx }) => {
      const { page, perPage, search } = input;
      const where = search
        ? { OR: [{ name: { contains: search } }, { email: { contains: search } }] }
        : {};
      
      const [users, total] = await Promise.all([
        ctx.prisma.user.findMany({
          where,
          skip: (page - 1) * perPage,
          take: perPage,
          orderBy: { createdAt: 'desc' },
        }),
        ctx.prisma.user.count({ where }),
      ]);
      
      return { users, total, pages: Math.ceil(total / perPage) };
    }),
  
  create: protectedProcedure
    .input(z.object({
      name: z.string().min(2).max(100),
      email: z.string().email(),
      role: z.enum(['USER', 'ADMIN']).default('USER'),
    }))
    .mutation(async ({ input, ctx }) => {
      const existing = await ctx.prisma.user.findUnique({
        where: { email: input.email },
      });
      if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'Email taken' });
      
      return ctx.prisma.user.create({ data: input });
    }),
  
  delete: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ input, ctx }) => {
      if (ctx.user.role !== 'ADMIN') {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      return ctx.prisma.user.delete({ where: { id: input.id } });
    }),
});

App Router

// server/routers/_app.ts
import { router } from '../trpc';
import { usersRouter } from './users';
import { postsRouter } from './posts';

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

export type AppRouter = typeof appRouter;

Next.js API Handler

// app/api/trpc/[trpc]/route.ts (App Router)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

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

export { handler as GET, handler as POST };

React Client Usage

// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';

const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
  trpc.createClient({
    transformer: superjson,
    links: [
      httpBatchLink({ url: '/api/trpc' }),
    ],
  })
);
// components/UserList.tsx
export function UserList() {
  const { data, isLoading } = trpc.users.list.useQuery({ page: 1, perPage: 20 });
  const deleteUser = trpc.users.delete.useMutation({
    onSuccess: () => utils.users.list.invalidate(),
  });
  
  if (isLoading) return <Spinner />;
  
  return (
    <ul>
      {data?.users.map(user => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => deleteUser.mutate({ id: user.id })}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Subscriptions (WebSocket)

// Server
export const postsRouter = router({
  onNewPost: publicProcedure.subscription(async function* () {
    for await (const post of pubsub.on('new-post')) {
      yield post;
    }
  }),
});

// Client
trpc.posts.onNewPost.useSubscription(undefined, {
  onData: (post) => {
    console.log('New post:', post);
  },
});

Why tRPC vs GraphQL

  • tRPC: Zero schema, TypeScript-only, simpler setup
  • GraphQL: Language-agnostic, introspection, flexible queries
  • Choose tRPC for TypeScript monorepos or Next.js projects
  • Choose GraphQL for public APIs with diverse clients