JavaScript 7 min read

tRPC: End-to-End Type-Safe APIs for TypeScript

Master tRPC for full-stack TypeScript applications. Learn procedures, routers, React Query integration, and build type-safe APIs without schemas.

MR

Moshiour Rahman

Advertisement

What is tRPC?

tRPC enables end-to-end type-safe APIs without code generation or schemas. Your TypeScript types flow from backend to frontend, catching errors at compile time.

tRPC Benefits

FeatureDescription
Type SafetyFull type inference
No CodegenNo schema files needed
AutocompletionIDE support everywhere
ValidationZod integration
React QueryBuilt-in integration

Getting Started

Installation

# Server
npm install @trpc/server zod

# Client
npm install @trpc/client @trpc/react-query @tanstack/react-query

Server Setup

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

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

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

Define Context

// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';

export async function createContext(opts: CreateNextContextOptions) {
  const session = await getSession({ req: opts.req });

  return {
    session,
    prisma,  // Database client
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

Procedures

Basic Procedures

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

export const userRouter = router({
  // Query - GET data
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.id }
      });

      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found'
        });
      }

      return user;
    }),

  // Mutation - Change data
  create: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email()
    }))
    .mutation(async ({ input, ctx }) => {
      const user = await ctx.prisma.user.create({
        data: input
      });
      return user;
    }),

  // Protected procedure
  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().optional(),
      bio: z.string().optional()
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.prisma.user.update({
        where: { id: ctx.session.user.id },
        data: input
      });
    }),

  // List with pagination
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().nullish()
    }))
    .query(async ({ input, ctx }) => {
      const { limit, cursor } = input;

      const users = await ctx.prisma.user.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: 'desc' }
      });

      let nextCursor: typeof cursor = undefined;
      if (users.length > limit) {
        const nextItem = users.pop();
        nextCursor = nextItem!.id;
      }

      return { users, nextCursor };
    })
});

Input Validation

import { z } from 'zod';

// Complex input schema
const createPostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
  published: z.boolean().default(false),
  tags: z.array(z.string()).max(5),
  metadata: z.object({
    seoTitle: z.string().optional(),
    seoDescription: z.string().optional()
  }).optional()
});

export const postRouter = router({
  create: protectedProcedure
    .input(createPostSchema)
    .mutation(async ({ input, ctx }) => {
      return ctx.prisma.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id
        }
      });
    })
});

// Infer types from schema
type CreatePostInput = z.infer<typeof createPostSchema>;

Middleware

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

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

const isAdmin = t.middleware(({ ctx, next }) => {
  if (ctx.session?.user?.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

// Logging middleware
const logger = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;

  console.log(`${type} ${path} - ${duration}ms`);

  return result;
});

export const protectedProcedure = t.procedure.use(isAuthed);
export const adminProcedure = t.procedure.use(isAuthed).use(isAdmin);
export const loggedProcedure = t.procedure.use(logger);

Root Router

// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { commentRouter } from './comment';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
  comment: commentRouter
});

export type AppRouter = typeof appRouter;

Client Setup

React Client

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

export const trpc = createTRPCReact<AppRouter>();

Provider Setup

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

const queryClient = new QueryClient();

const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: '/api/trpc',
      headers() {
        return {
          Authorization: getAuthToken()
        };
      }
    })
  ]
});

function App({ Component, pageProps }) {
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <Component {...pageProps} />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Using tRPC in Components

Queries

import { trpc } from '../utils/trpc';

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = trpc.user.getById.useQuery({ id: userId });

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

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
}

function UserList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    trpc.user.list.useInfiniteQuery(
      { limit: 10 },
      {
        getNextPageParam: (lastPage) => lastPage.nextCursor
      }
    );

  return (
    <div>
      {data?.pages.map((page) =>
        page.users.map((user) => (
          <div key={user.id}>{user.name}</div>
        ))
      )}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Mutations

import { trpc } from '../utils/trpc';

function CreateUserForm() {
  const utils = trpc.useUtils();

  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Invalidate and refetch
      utils.user.list.invalidate();
    },
    onError: (error) => {
      alert(error.message);
    }
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    createUser.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

Optimistic Updates

function TodoList() {
  const utils = trpc.useUtils();

  const toggleTodo = trpc.todo.toggle.useMutation({
    onMutate: async ({ id }) => {
      // Cancel outgoing refetches
      await utils.todo.list.cancel();

      // Snapshot previous value
      const previousTodos = utils.todo.list.getData();

      // Optimistically update
      utils.todo.list.setData(undefined, (old) =>
        old?.map((todo) =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        )
      );

      return { previousTodos };
    },
    onError: (err, variables, context) => {
      // Rollback on error
      utils.todo.list.setData(undefined, context?.previousTodos);
    },
    onSettled: () => {
      // Refetch after error or success
      utils.todo.list.invalidate();
    }
  });

  // ...
}

Error Handling

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

// Server-side errors
export const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.prisma.post.findUnique({
        where: { id: input.id }
      });

      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Post not found'
        });
      }

      if (post.authorId !== ctx.session.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You can only delete your own posts'
        });
      }

      return ctx.prisma.post.delete({
        where: { id: input.id }
      });
    })
});

// Client-side error handling
function DeleteButton({ postId }: { postId: string }) {
  const deletePost = trpc.post.delete.useMutation({
    onError: (error) => {
      if (error.data?.code === 'FORBIDDEN') {
        alert('You cannot delete this post');
      } else {
        alert(error.message);
      }
    }
  });

  return (
    <button onClick={() => deletePost.mutate({ id: postId })}>
      Delete
    </button>
  );
}

Next.js Integration

API Route Handler

// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createContext } from '../../../server/context';

export default createNextApiHandler({
  router: appRouter,
  createContext,
  onError({ error }) {
    console.error('tRPC error:', error);
  }
});

Server-Side Rendering

// pages/user/[id].tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '../../server/routers/_app';
import { createContext } from '../../server/context';
import superjson from 'superjson';

export async function getServerSideProps(context) {
  const helpers = createServerSideHelpers({
    router: appRouter,
    ctx: await createContext(context),
    transformer: superjson
  });

  const id = context.params?.id as string;

  await helpers.user.getById.prefetch({ id });

  return {
    props: {
      trpcState: helpers.dehydrate(),
      id
    }
  };
}

function UserPage({ id }: { id: string }) {
  // This will be instantly available since we prefetched
  const { data } = trpc.user.getById.useQuery({ id });

  return <div>{data?.name}</div>;
}

Summary

FeatureUsage
Querytrpc.router.procedure.useQuery()
Mutationtrpc.router.procedure.useMutation()
InfiniteuseInfiniteQuery()
Invalidateutils.router.procedure.invalidate()
Prefetchhelpers.router.procedure.prefetch()

tRPC provides seamless end-to-end type safety for TypeScript full-stack applications.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.