DEV Community

Alex Spinov
Alex Spinov

Posted on

tRPC Has a Free API — End-to-End Type Safety Without GraphQL

tRPC is the end-to-end typesafe API framework that eliminates the gap between your frontend and backend — no code generation, no schemas, just TypeScript. And it's completely free.

Why tRPC?

With REST or GraphQL, your frontend and backend types are disconnected. Change an API response? Your frontend breaks silently at runtime. tRPC makes this impossible:

  • End-to-end type safety — change the backend, frontend shows errors immediately
  • No code generation — types flow automatically through TypeScript
  • No schemas — no GraphQL SDL, no OpenAPI spec
  • Autocomplete — full IntelliSense for all API calls
  • Subscriptions — real-time via WebSocket
  • Batching — multiple calls in one HTTP request

Quick Start

npm install @trpc/server @trpc/client @trpc/react-query zod
Enter fullscreen mode Exit fullscreen mode

Server (Define Your API)

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

const t = initTRPC.create();

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

// Define your API
export const appRouter = router({
  // Query (GET)
  hello: publicProcedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello, ${input.name}!` };
    }),

  // Query with database
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input.id } });
      if (!user) throw new TRPCError({ code: "NOT_FOUND" });
      return user;
    }),

  // Mutation (POST/PUT/DELETE)
  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(2),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return db.user.create({ data: input });
    }),

  // Nested routers
  posts: router({
    list: publicProcedure
      .input(z.object({ limit: z.number().default(10) }))
      .query(async ({ input }) => {
        return db.post.findMany({ take: input.limit });
      }),

    create: publicProcedure
      .input(z.object({
        title: z.string(),
        content: z.string(),
      }))
      .mutation(async ({ input }) => {
        return db.post.create({ data: input });
      }),
  }),
});

// Export the type — this is the magic!
export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

Client (Full Type Safety)

// client/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/trpc";

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "http://localhost:3000/trpc",
    }),
  ],
});

// Full autocomplete — try it!
const greeting = await trpc.hello.query({ name: "World" });
// greeting.greeting is typed as string

const user = await trpc.getUser.query({ id: "123" });
// user.name, user.email — all typed!

const newUser = await trpc.createUser.mutate({
  name: "Alice",
  email: "alice@example.com",
  // age: 25  ← TypeScript ERROR: 'age' does not exist
});

const posts = await trpc.posts.list.query({ limit: 5 });
// posts is typed as Post[]
Enter fullscreen mode Exit fullscreen mode

React Integration (@trpc/react-query)

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

export const trpc = createTRPCReact<AppRouter>();
Enter fullscreen mode Exit fullscreen mode
// components/UserProfile.tsx
import { trpc } from "../utils/trpc";

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

  const updateUser = trpc.updateUser.useMutation({
    onSuccess: () => {
      // Invalidate cache
      utils.getUser.invalidate({ id: userId });
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button onClick={() => updateUser.mutate({ id: userId, name: "New Name" })}>
        Update Name
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Authentication Middleware

const t = initTRPC.context<{ user?: { id: string; role: string } }>().create();

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

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

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

export const appRouter = router({
  // Public
  hello: publicProcedure.query(() => "Hello!"),

  // Requires login
  myProfile: protectedProcedure.query(({ ctx }) => {
    return db.user.findUnique({ where: { id: ctx.user.id } });
  }),

  // Requires admin
  deleteUser: adminProcedure
    .input(z.object({ userId: z.string() }))
    .mutation(({ input }) => {
      return db.user.delete({ where: { id: input.userId } });
    }),
});
Enter fullscreen mode Exit fullscreen mode

tRPC vs REST vs GraphQL

Feature tRPC REST GraphQL
Type safety End-to-end Manual (OpenAPI) Code generation
Schema None needed OpenAPI spec SDL required
Overfetching No (TypeScript) Common No (queries)
Learning curve Low (just TS) Low Medium
Batching Built-in Manual Built-in
File uploads Via formData Native Complex
Caching React Query HTTP cache Apollo cache
Best for TS fullstack Public APIs Multi-client

Need to scrape data from any website and get it in structured JSON? Check out my web scraping tools on Apify — no coding required, results in minutes.

Have a custom data extraction project? Email me at spinov001@gmail.com — I build tailored scraping solutions for businesses.

Top comments (0)