DEV Community 👩‍💻👨‍💻

Shoubhit Dash
Shoubhit Dash

Posted on

Build end-to-end typesafe APIs with tRPC

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.

npx create-next-app next-with-trpc --ts
Enter fullscreen mode Exit fullscreen mode

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.

.
├── src
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── api
│   │   │   
│   │   └── [..]
└── [..]
Enter fullscreen mode Exit fullscreen mode

Once this is done, let's install tRPC.

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

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>();
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"}!` };
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

.
├── src
│   ├── pages
│   │   ├── _app.tsx 
│   │   ├── api
│   │   │   └── trpc
│   │   │       └── [trpc].ts 
│   │   └── [..]
│   ├── server
│   │   ├── routers
│   │   │   ├── app.ts   
│   │   │   ├── name.ts  
│   │   │   └── [..]
│   │   ├── context.ts      
│   │   └── createRouter.ts 
└── [..]
Enter fullscreen mode Exit fullscreen mode

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);
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

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.ts

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);
Enter fullscreen mode Exit fullscreen mode

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:

.
├── src
│   ├── pages
│   │   ├── _app.tsx 
│   │   ├── api
│   │   │   └── trpc
│   │   │       └── [trpc].ts 
│   │   └── [..]
│   ├── server
│   │   ├── routers
│   │   │   ├── app.ts   
│   │   │   ├── name.ts  
│   │   │   └── [..]
│   │   ├── context.ts      
│   │   └── createRouter.ts 
|   └── utils
│       └── trpc.ts
└── [..]
Enter fullscreen mode Exit fullscreen mode

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]>;
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)

🌚 Life is too short to browse without dark mode