Build end to end typesafe APIs with tRPC

12 min read

Update: This blog post was written before tRPC v10 was released. You should use tRPC v10 now.

tRPC is a tool that provides type-safety between your front and back-ends, hence it makes it really easy to build scalable and robust backends quickly for your Next.js and Node apps. In this article we will be looking at what tRPC is, and how to set it up and use it with Next.js.

What is tRPC?

tRPC is a very light library which lets you build fully typesafe APIs without the need of schemas or code generation. It allows sharing of types between the client and server and just imports the types and not the actual server code, so none of the server code is exposed in the frontend. With end-to-end type-safety, you're able to catch errors between your frontend and backend at compile and build time.

Currently, the dominant way of making typesafe APIs is using GraphQL (it's awesome). Since GraphQL is a query language for APIs, it doesn't take full advantage of TypeScript, it comes with excess boilerplate code and requires a lot of initial setup.

If you're already using TypeScript everywhere, you can share types directly between the client and the server without the need of any code generation.

Installing tRPC in Next.js

Lets first create a Next.js project with TypeScript.

terminal
npx create-next-app next-with-trpc --ts

Let's now use the recommended folder structure by tRPC. Open the project in your favourite editor, make a src directory and move the pages and styles directory inside it. Your folder structure should look something like this.

folder-structure
.
├── src
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── api
│   │   │
│   │   └── [..]
└── [..]

Once this is done, let's install tRPC.

terminal
npm i @trpc/client @trpc/server @trpc/react @trpc/next zod react-query

tRPC is a built on top of react-query, which is a package for fetching, caching, and updating data without the need of any global state. We are also using zod for schema and input validations. You can also use other validation libraries like yup, Superstruct, etc. Read more.

Also make sure that in your tsconfig.json, strict mode is enabled.

tsconfig.json
{
  // ...
  "compilerOptions": {
    // ...
    "strict": true
  }
}

This is so that zod can run correctly, and in general, having strict mode enabled is just good.

Creating our server

Our tRPC server will be deployed as a Next.js API route. This code only runs on the server so it doesn't affect the bundle sizes in any way.

Creating our context

Let's first create a directory inside src called server. In here, we need to first create a context. Create a file called context.ts and add the following code to it.

src/server/context.ts
import { CreateNextContextOptions } from "@trpc/server/adapters/next";
import { inferAsyncReturnType } from "@trpc/server";

export async function createContext(ctxOptions?: CreateNextContextOptions) {
  const req = ctxOptions?.req;
  const res = ctxOptions?.res;

  return {
    req,
    res,
  };
}

export type MyContextType = inferAsyncReturnType<typeof createContext>;

This will be available as ctx in all our resolvers which we'll write in a bit. Right now, we're just passing the request and response to our routes, but you can add other things like JWT tokens, cookies or even Prisma Client code.

Creating our router

Now let's create a file called createRouter.ts in the server directory. We'll be setting up a simple router. Copy the following code to it.

src/server/createRouter.ts
import * as trpc from "@trpc/server";

import type { MyContextType } from "./context";

export function createRouter() {
  return trpc.router<MyContextType>();
}

Creating our routes

Let's create a new directory inside src/server called routers. Make a file called app.ts inside it. This will be the root route.

src/server/routers/app.ts
import { createRouter } from "../createRouter";

export const appRouter = createRouter();

export type AppRouter = typeof appRouter;

Now let's create a router that takes a name as input and returns it to the client. Add a file called name.ts.

src/server/routers/name.ts
import { z } from "zod";

import { createRouter } from "../createRouter";

export const nameRouter = createRouter().query("getName", {
  input: z
    .object({
      name: z.string().nullish(),
    })
    .nullish(),
  resolve({ input }) {
    return { greeting: `Hello ${input?.name ?? "world"}!` };
  },
});

Just like GraphQL, tRPC uses queries and mutations. A query is used for fetching data and mutations are used to create, update, and delete data. Here we are creating a query to get a name. The name of our query is getName. Here, input takes the user input which is validated using zod. When this endpoint is requested, the resolve function is called and it returns the hello world greeting. because why not.

Now let's merge this route in our root route. Come back to app.ts and add the following code.

src/server/routers/app.ts
import { createRouter } from "../createRouter";
import { nameRouter } from "./name";

export const appRouter = createRouter().merge("names.", nameRouter);

export type AppRouter = typeof appRouter;

That . at the end of names. is intentional for reasons you'll see soon.

Creating our Next.js API route

Let's create a trpc directory inside src/pages/api. Inside it create a file called [trpc].ts. Just a reminder of our folder structure.

folder-structure
.
├── src
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── api
│   │   │   └── trpc
│   │   │       └── [trpc].ts
│   │   └── [..]
│   ├── server
│   │   ├── routers
│   │   │   ├── app.ts
│   │   │   ├── name.ts
│   │   │   └── [..]
│   │   ├── context.ts
│   │   └── createRouter.ts
└── [..]

Here we will implement our tRPC router. As I had said before, our server will be deployed as a Next.js API route.

src/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,
  batching: {
    enabled: true,
  },
  onError({ error }) {
    if (error.code === "INTERNAL_SERVER_ERROR") {
      console.error("Something went wrong", error);
    }
  },
});

We're passing it our router, our createConext function, enabling batching and logging errors.

With that we're pretty much done with the backend. Let's now work on our frontend.

Calling our tRPC API routes

Let's now connect our backend with our frontend. Go to src/pages/_app.tsx. Here we are going to configure tRPC and React Query. Copy the following code.

src/pages/_app.tsx
import { AppType } from "next/dist/shared/lib/utils";
import { withTRPC } from "@trpc/next";

import { AppRouter } from "./api/trpc/[trpc]";
import "../styles/globals.css";

const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

const getBaseUrl = () => {
  if (process.browser) return "";
  if (process.env.NEXT_PUBLIC_VERCEL_URL)
    return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`;

  return `http://localhost:${process.env.PORT ?? 3000}`;
};

export default withTRPC<AppRouter>({
  config({ ctx }) {
    const url = `${getBaseUrl()}/api/trpc`;

    return {
      url,
    };
  },
  ssr: false,
})(MyApp);

We're also setting ssr (Server Side Rendering) to be false for now.

Next, create a utils directory inside src. Inside utils, create a file called trpc.ts. Folder structure for reference:

folder-structure
.
├── src
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── api
│   │   │   └── trpc
│   │   │       └── [trpc].ts
│   │   └── [..]
│   ├── server
│   │   ├── routers
│   │   │   ├── app.ts
│   │   │   ├── name.ts
│   │   │   └── [..]
│   │   ├── context.ts
│   │   └── createRouter.ts
|   └── utils
│       └── trpc.ts
└── [..]

Here we're going to create a hook to use tRPC on the client.

src/utils/trpc.ts
import { createReactQueryHooks } from "@trpc/react";
import { inferProcedureOutput } from "@trpc/server";

import { AppRouter } from "../server/routers/app";

export const trpc = createReactQueryHooks<AppRouter>();

export type inferQueryOutput<
  TRouteKey extends keyof AppRouter["_def"]["queries"]
> = inferProcedureOutput<AppRouter["_def"]["queries"][TRouteKey]>;

This hook is strongly typed using our API type signature. This is what enables the end-to-end typesafety in our code. For example, if we change a router's name in the backend, it will show an error in the frontend where we're calling the route. It allows us to call our backend and get fully typed inputs and outputs from it. It also gives us wonderful autocomplete.

Let's now actually use our query that we defined earlier. Go to the index page and instead of copy pasting this code block, type it yourself to experience the magic of tRPC. The autocomplete is just crazy.

src/pages/index.tsx
import { trpc } from "../utils/trpc";

export default function Name() {
  const nameQuery = trpc.useQuery(["names.getName", { name: "nexxel" }]);

  return (
    <>
      {nameQuery.data ? (
        <h1>{nameQuery.data.greeting}</h1>
      ) : (
        <span>Loading...</span>
      )}
    </>
  );
}

Did you see the magic? In case you're lazy and just copied the code, I got you covered ;) Look at this 😱.

autocomplete

autocomplete

The autocomplete is soo good. And it catches any kind of type errors you make without the need of declaring any types or interfaces manually. It's crazy good.

Now, start the dev server and see the greeting on the screen!

loading

Notice the loading state. This is because we set ssr to false when we configured tRPC in _app.tsx. So it's being client side rendered. Go to src/pages/_app.tsx and set ssr to true now and see what happens.

not loading

Now there is no loading state because the data is being fetched server side and then it is being rendered. You can use whatever suits your needs.

Conclusion

In this article, we looked at what tRPC is and how to use it with a Next.JS app. tRPC makes building typesafe APIs incredibly easy and improves the DX by a million times. You can use it with not only Next.js but also React, Node and there's adapters being developed for Vue and Svelte as well. I recommend using it for pretty much any project you make now. For more information checkout the tRPC docs.

Code: https://github.com/nexxeln/trpc-nextjs

Thank you for reading.