Skip to main content
Back to Blog
Tutorials
3 min read
November 29, 2024

How to Add End-to-End Type Safety with tRPC in Next.js

Set up tRPC in Next.js for end-to-end type safety between your frontend and backend without code generation.

Ryel Banfield

Founder & Lead Developer

tRPC gives you type-safe API calls without REST endpoints or code generation. Your frontend knows exactly what your backend returns.

Step 1: Install Dependencies

pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query superjson zod

Step 2: Initialize tRPC Server

// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/lib/auth";

export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await auth();
  return {
    session,
    ...opts,
  };
};

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

export const createCallerFactory = t.createCallerFactory;
export const createTRPCRouter = t.router;

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

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

Step 3: Define Routers

// server/routers/posts.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq, desc } from "drizzle-orm";

export const postsRouter = createTRPCRouter({
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(20),
        cursor: z.string().optional(),
      })
    )
    .query(async ({ input }) => {
      const items = await db
        .select()
        .from(posts)
        .orderBy(desc(posts.createdAt))
        .limit(input.limit + 1);

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

      return { items, nextCursor };
    }),

  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const [post] = await db
        .select()
        .from(posts)
        .where(eq(posts.id, input.id));

      if (!post) throw new TRPCError({ code: "NOT_FOUND" });
      return post;
    }),

  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1).max(200),
        content: z.string().min(1),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const [post] = await db
        .insert(posts)
        .values({
          ...input,
          authorId: ctx.session.user.id,
        })
        .returning();

      return post;
    }),

  update: protectedProcedure
    .input(
      z.object({
        id: z.string(),
        title: z.string().min(1).max(200).optional(),
        content: z.string().min(1).optional(),
        published: z.boolean().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      const [post] = await db
        .update(posts)
        .set(data)
        .where(eq(posts.id, id))
        .returning();

      return post;
    }),

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

Step 4: Root Router

// server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { postsRouter } from "./posts";

export const appRouter = createTRPCRouter({
  posts: postsRouter,
});

export type AppRouter = typeof appRouter;

Step 5: tRPC API Route Handler

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

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

export { handler as GET, handler as POST };

Step 6: Client Setup

// lib/trpc.ts
"use client";

import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";

export const trpc = createTRPCReact<AppRouter>();
// components/providers/TRPCProvider.tsx
"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { trpc } from "@/lib/trpc";

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

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

Step 7: Use in Components

"use client";

import { trpc } from "@/lib/trpc";

export function PostList() {
  // Fully type-safe query β€” IDE autocomplete works
  const { data, isLoading } = trpc.posts.list.useQuery({ limit: 10 });

  const createPost = trpc.posts.create.useMutation({
    onSuccess: () => {
      // Invalidate the list query to refetch
      utils.posts.list.invalidate();
    },
  });

  const utils = trpc.useUtils();

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

  return (
    <div>
      <button
        onClick={() =>
          createPost.mutate({
            title: "New Post",
            content: "Hello world",
          })
        }
      >
        Create Post
      </button>

      {data?.items.map((post) => (
        <div key={post.id}>
          <h2>{post.title}</h2>
          {/* post.title is typed as string */}
          {/* post.nonexistent would show a TypeScript error */}
        </div>
      ))}
    </div>
  );
}

Need a Type-Safe Web Application?

We build robust web applications with full type safety, modern tooling, and clean architecture. Contact us to discuss your project.

tRPCtype safetyTypeScriptAPINext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles