Jump to content

tRPC

tRPC allows us to write end-to-end typesafe APIs without any code generation or runtime bloat. It uses TypeScript’s great inference to infer your API router’s type definitions and lets you call your API procedures from your frontend with full typesafety and autocompletion. When using tRPC, your front- and backend feel closer together than ever before, allowing for an outstanding developer experience.

I built tRPC to allow people to move faster by removing the need of a traditional API-layer, while still having confidence that our apps won't break as we rapidly iterate.

Avatar of @alexdotjs
Alex - creator of tRPC @alexdotjs

Files

tRPC requires quite a lot of boilerplate that create-t3-app sets up for you. Let’s go over the files that are generated:

đź“„ pages/api/trpc/[trpc].ts

This is the entry point for your API and exposes the tRPC router. Normally, you won’t touch this file very much, but if you need to, for example, enable CORS middleware or similar, it’s useful to know that the exported createNextApiHandler is a Next.js API handler which takes a request and response object. This means that you can wrap the createNextApiHandler in any middleware you want. See below for an example snippet of adding CORS.

đź“„ server/trpc/context.ts

This file is where you define the context that is passed to your tRPC procedures. Context is data that all of your tRPC procedures will have access to, and is a great place to put things like database connections, authentication information, etc. In create-t3-app we use two functions, to enable using a subset of the context when we do not have access to the request object.

  • createContextInner: This is where you define context which doesn’t depend on the request, e.g. your database connection. You can use this function for integration testing or ssg-helpers where you don’t have a request object.

  • createContext: This is where you define context which depends on the request, e.g. the user’s session. You request the session using the opts.req object, and then pass the session down to the createContextInner function to create the final context.

đź“„ server/trpc/trpc.ts

This is where you initialize tRPC and define reusable procedures and middlewares. By convention, you shouldn’t export the entire t-object but instead, create reusable procedures and middlewares and export those.

You’ll notice we use superjson as data transformer. This makes it so that your data types are preserved when they reach the client, so if you for example send a Date object, the client will return a Date and not a string which is the case for most APIs.

đź“„ server/trpc/router/*.ts

This is where you define the routes and procedures of your API. By convention, you create separate routers for related procedures, then merge all of them into a single app router in server/trpc/router/_app.ts.

đź“„ utils/trpc.ts

This is the frontend entry point for tRPC. This is where you’ll import the router’s type definition and create your tRPC client along with the react-query hooks. Since we enabled superjson as our data transformer on the backend, we need to enable it on the frontend as well. This is because the serialized data from the backend is deserialized on the frontend.

You’ll define your tRPC links here, which determines the request flow from the client to the server. We use the “default” httpBatchLink which enables request batching, as well as a loggerLink which outputs useful request logs during development.

Lastly, we export a helper type which you can use to infer your types on the frontend.

How do I use tRPC?

tRPC contributor trashh_dev made a killer talk at Next.js Conf about tRPC. We highly recommend you watch it if you haven’t already.

With tRPC, you write TypeScript functions on your backend, and then call them from your frontend. A simple tRPC procedure could look like this:

server/trpc/router/user.ts
const userRouter = t.router({
  getById: t.procedure.input(z.string()).query(({ ctx, input }) => {
    return ctx.prisma.user.findFirst({
      where: {
        id: input,
      },
    });
  }),
});

This is a tRPC procedure (equivalent to a route handler in a traditional backend) that first validates the input using Zod (which is the same validation library that we use for environment variables) - in this case, it’s making sure that the input is a string. If the input is not a string it will send an informative error instead.

After the input, we chain a resolver function which can be either a query, mutation, or a subscription. In our example, the resolver calls our database using our prisma client and returns the user whose id matches the one we passed in.

You define your procedures in routers which represent a collection of related procedures with a shared namespace. You may have one router for users, one for posts, and another one for messages. These routers can then be merged into a single, centralized appRouter:

server/trpc/router/_app.ts
const appRouter = t.router({
  users: userRouter,
  posts: postRouter,
  messages: messageRouter,
});

export type AppRouter = typeof appRouter;

Notice that we only need to export our router’s type definitions, which means we are never importing any server code on our client.

Now let’s call the procedure on our frontend. tRPC provides a wrapper for @tanstack/react-query which lets you utilize the full power of the hooks they provide, but with the added benefit of having your API calls typed and inferred. We can call our procedures from our frontend like this:

pages/users/[id].tsx
import { useRouter } from "next/router";

const UserPage = () => {
  const { query } = useRouter();
  const userQuery = trpc.user.getById.useQuery(query.id);

  return (
    <div>
      <h1>{userQuery.data?.name}</h1>
    </div>
  );
};

You’ll immediately notice how good the autocompletion and typesafety is. As soon as you write trpc., your routers will show up in autocomplete, and when you select a router, its procedures will show up as well. You’ll also get a TypeScript error if your input doesn’t match the validator that you defined on the backend.

How do I call my API externally?

With regular APIs, you can call your endpoints using any HTTP client such as curl, Postman, fetch or straight from your browser. With tRPC, it’s a bit different. If you want to call your procedures without the tRPC client, there are two recommended ways to do it:

Expose a single procedure externally

If you want to expose a single procedure externally, you’re looking for server side calls. That would allow you to create a normal Next.js API endpoint, but reuse the resolver part of your tRPC procedure.

pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { appRouter } from "../../../server/trpc/router/_app";
import { createContext } from "../../../server/trpc/context";

const userByIdHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  // Create context and caller
  const ctx = await createContext({ req, res });
  const caller = appRouter.createCaller(ctx);
  try {
    const { id } = req.query;
    const user = await caller.user.getById(id);
    res.status(200).json(user);
  } catch (cause) {
    if (cause instanceof TRPCError) {
      // An error from tRPC occured
      const httpCode = getHTTPStatusCodeFromError(cause);
      return res.status(httpCode).json(cause);
    }
    // Another error occured
    console.error(cause);
    res.status(500).json({ message: "Internal server error" });
  }
};

export default userByIdHandler;

Exposing every procedure as a REST endpoint

If you want to expose every single procedure externally, checkout the community built plugin trpc-openapi. By providing some extra meta-data to your procedures, you can generate an OpenAPI compliant REST API from your tRPC router.

It’s just HTTP Requests

tRPC communicates over HTTP, so it is also possible to call your tRPC procedures using “regular” HTTP requests. However, the syntax can be cumbersome due to the RPC protocol that tRPC uses. If you’re curious, you can check what tRPC requests and responses look like in your browser’s network tab, but we suggest doing this only as an educational exercise and sticking to one of the solutions outlined above.

Comparison to a Next.js API endpoint

Let’s compare a Next.js API endpoint to a tRPC procedure. Let’s say we want to fetch a user object from our database and return it to the frontend. We could write a Next.js API endpoint like this:

pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../server/db/client";

const userByIdHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== "GET") {
    return res.status(405).end();
  }

  const { id } = req.query;

  if (!id || typeof id !== "string") {
    return res.status(400).json({ error: "Invalid id" });
  }

  const examples = await prisma.example.findFirst({
    where: {
      id,
    },
  });

  res.status(200).json(examples);
};

export default userByIdHandler;
pages/users/[id].tsx
import { useState, useEffect } from "react";
import { useRouter } from "next/router";

const UserPage = () => {
  const router = useRouter();
  const { id } = router.query;

  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/user/${id}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [id]);
};

Compare this to the tRPC example above and you can see some of the advantages of tRPC:

  • Instead of specifying a url for each route, which can become annoying to debug if you move something, your entire router is an object with autocomplete.
  • You don’t need to validate which HTTP method was used.
  • You don’t need to validate that the request query or body contains the correct data in the procedure, because Zod takes care of this.
  • Instead of creating a response, you can throw errors and return a value or object as you would in any other TypeScript function.
  • Calling the procedure on the frontend provides autocompletion and type safety.

Useful snippets

Here are some snippets that might come in handy.

Enabling CORS

If you need to consume your API from a different domain, for example in a monorepo that includes a React Native app, you might need to enable CORS:

pages/api/trpc/[trpc].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "~/server/trpc/router/_app";
import { createContext } from "~/server/trpc/context";
import cors from "nextjs-cors";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  // Enable cors
  await cors(req, res);

  // Create and call the tRPC handler
  return createNextApiHandler({
    router: appRouter,
    createContext,
  })(req, res);
};

export default handler;

Optimistic updates

Optimistic updates are when we update the UI before the API call has finished. This gives the user a better experience because they don’t have to wait for the API call to finish before the UI reflects the result of their action. However, apps that value data correctness highly should avoid optimistic updates as they are not a “true” representation of backend state. You can read more on the React Query docs.

const MyComponent = () => {
  const listPostQuery = trpc.post.list.useQuery();

  const utils = trpc.useContext();
  const postCreate = trpc.post.create.useMutation({
    async onMutate(newPost) {
      // Cancel outgoing fetches (so they don't overwrite our optimistic update)
      await utils.post.list.cancel();

      // Get the data from the queryCache
      const prevData = utils.post.list.getData();

      // Optimistically update the data with our new post
      utils.post.list.setData(undefined, (old) => [...old, newPost]);

      // Return the previous data so we can revert if something goes wrong
      return { prevData };
    },
    onError(err, newPost, ctx) {
      // If the mutation fails, use the context-value from onMutate
      utils.post.list.setData(undefined, ctx.prevData);
    },
    onSettled() {
      // Sync with server once mutation has settled
      utils.post.list.invalidate();
    },
  });
};

Sample Integration Test

Here is a sample integration test that uses Vitest to check that your tRPC router is working as expected, the input parser infers the correct type, and that the returned data matches the expected output.

import { type inferProcedureInput } from "@trpc/server";
import { createContextInner } from "~/server/router/context";
import { appRouter, type AppRouter } from "~/server/router/_app";
import { expect, test } from "vitest";

test("example router", async () => {
  const ctx = await createContextInner({ session: null });
  const caller = appRouter.createCaller(ctx);

  type Input = inferProcedureInput<AppRouter["example"]["hello"]>;
  const input: Input = {
    text: "test",
  };

  const example = await caller.example.hello(input);

  expect(example).toMatchObject({ greeting: "Hello test" });
});

Useful Resources

ResourceLink
tRPC Docshttps://www.trpc.io
Bunch of tRPC Exampleshttps://github.com/trpc/trpc/tree/next/examples
React Query Docshttps://tanstack.com/query/v4/docs/adapters/react-query