tRPC Tutorial 2026 (End-to-End TypeScript API)

tRPC is an end-to-end type-safe API layer for TypeScript projects. You define server procedures once, and your client gets fully-typed function calls with autocomplete, no code generation, no OpenAPI, no GraphQL schema. In 2026 it is one of the most-loved data-fetching solutions for full-stack TypeScript apps. This tutorial walks through setup, queries, mutations, input validation with zod, and the trade-offs against REST and GraphQL.

Quick 2026 verdict

Use tRPC when your client AND server are both TypeScript, live in the same repo, and are deployed together (Next.js, Nuxt with Nitro, SvelteKit, Astro). Do NOT use tRPC when the API needs to serve non-TypeScript clients (mobile apps in Kotlin/Swift, third-party integrations), REST or GraphQL still win there. Server Actions in Next.js 15 cover the simplest tRPC use cases, but tRPC still wins for teams that want a structured, testable API layer.

What tRPC actually solves

Without tRPC, a typical Next.js app has:

  • Server code that defines API routes (app/api/users/route.ts)
  • Client code that fetches from those routes (fetch('/api/users'))
  • Manually-written TypeScript types shared between them (or drift between server and client shapes)

With tRPC, you define one function on the server and call it directly from the client with full type safety. No hand-written types, no drift.

Install tRPC in a Next.js project

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

Define your first router

Create the tRPC instance and root router:

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

const t = initTRPC.create();

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

Now write actual procedures. Example: a users router with list, get-by-id, and create:

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

export const usersRouter = router({

  list: publicProcedure.query(async () => {
    return await db.users.findMany();
  }),

  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.users.findUnique({ where: { id: input.id } });
    }),

  create: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return await db.users.create({ data: input });
    }),

});

Compose all routers into one root router:

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

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

export type AppRouter = typeof appRouter;

The export type AppRouter line is the magic: this type is what the client imports to get full autocomplete.

Wire the Next.js API route

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

Client setup with React Query

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

export const trpc = createTRPCReact<AppRouter>();

Provider setup (once, high in the tree):

// src/app/Providers.tsx
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/lib/trpc';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [httpBatchLink({ url: '/api/trpc' })],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Call your API from a component

'use client';
import { trpc } from '@/lib/trpc';

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

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

  return (
    <ul>
      {data?.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

Notice: full autocomplete on trpc.users.list.useQuery(). The data variable is fully typed as the return of the server-side query. Rename the server-side procedure, and the client breaks at compile time with a helpful error.

Mutations

'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc';

export function CreateUserForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const utils = trpc.useUtils();

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

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      create.mutate({ name, email });
    }}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button disabled={create.isPending}>Create</button>
      {create.error && <p>{create.error.message}</p>}
    </form>
  );
}

Input validation with zod

Zod schemas double as runtime validation AND TypeScript type inference. The .input(z.object(...)) chain validates the request body before your procedure runs.

import { z } from 'zod';

// Reusable schema
const CreateUserSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
  age: z.number().int().positive().optional(),
});

// Use in a procedure
export const createUser = publicProcedure
  .input(CreateUserSchema)
  .mutation(async ({ input }) => {
    // input is fully typed AND validated
    return await db.users.create({ data: input });
  });

Bad input never reaches your handler. Zod returns a structured error and tRPC forwards it to the client with proper HTTP status.

Context and authentication

// Extend the context to include the current user
export async function createContext({ req }: { req: Request }) {
  const session = await getSession(req);
  return { session };
}

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

// Protected procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user: ctx.session.user } });
});

// Now use it
const meRouter = router({
  profile: protectedProcedure.query(({ ctx }) => {
    return db.users.findUnique({ where: { id: ctx.user.id } });
  }),
});

tRPC vs REST vs GraphQL vs Server Actions

AspecttRPCRESTGraphQLServer Actions
End-to-end typesNativeManual or OpenAPICodegenNative
Non-TS clientsNot supportedYesYesNot supported
Learning curveLow (TS-native)LowMedium-HighVery Low
Query flexibilityFixed proceduresFixed endpointsHighly flexibleFixed functions
Ecosystem toolingGrowingMassiveMassiveFramework-locked

When NOT to use tRPC

  • Public API for third parties (they expect REST or GraphQL, not fetch('/api/trpc') + JSON-encoded procedure paths)
  • Non-TypeScript clients (React Native with Kotlin/Swift native modules, mobile apps in Flutter, backend-only Python or Go consumers)
  • Very simple app where Server Actions in Next.js 15 already cover 80% of your data needs
  • Team fundamentally divided between TS-only and non-TS members

Frequently Asked Questions

Is tRPC still relevant with Server Actions?

Yes for medium-to-large apps. Server Actions are great for form submissions and simple mutations. tRPC gives you a structured API layer with routers, middleware, testability, and a single place to define all server-side procedures. Many teams use BOTH: Server Actions for simple form submits, tRPC for the rest of the app’s data layer.

Does tRPC work outside Next.js?

Yes. tRPC has adapters for Express, Fastify, standalone Node, AWS Lambda, Cloudflare Workers, Deno, Bun, SvelteKit, Nuxt, Astro. Anywhere you can run TypeScript on both sides, tRPC works.

Can I generate OpenAPI from tRPC?

Yes, via trpc-openapi. This is the common bridge when a mostly-TypeScript app needs to expose some endpoints to non-TS consumers (mobile team, third-party integration). Not every procedure needs to be REST-compatible; you opt in per-procedure.

Does tRPC support subscriptions or real-time?

Yes, via WebSocket links (wsLink). Server-Sent Events (SSE) support arrived in tRPC 11 (late 2024). For most cases you can also use polling with React Query’s refetchInterval, which is simpler.

How is auth handled?

Through context + middleware. Your createContext reads the session (from cookies, JWT, or Auth.js/Clerk) and injects it into every procedure. A protectedProcedure middleware throws UNAUTHORIZED if there is no user. This composes cleanly with Auth.js, Clerk, Lucia, and any other TypeScript auth library.

Is there a way to test tRPC procedures?

Yes. You can call procedures directly using appRouter.createCaller(context) in unit tests without spinning up an HTTP server. That gives you a fast integration test path with real business logic + mocked context.

Related Modern Web Dev tutorials

  • Next.js 15 Complete Beginner Guide 2026 (First App)
  • TypeScript Complete Beginner Tutorial 2026
  • Server Components vs Client Components 2026
  • Zustand vs Redux Toolkit 2026 (coming this week)

Official documentation

Leave a Comment