Get End-to-End Typesafe APIs With tRPC and Next.js App Router

BACK
tRPC + Nextjs = End-to-End Type Safe APIs

REST and GraphQL have been the popular choices for building and consuming APIs in the last 7-10 years. Recently, TypeScript Remote Procedure Call (tRPC) has been making a splash promising end-to-end type safe APIs. On the backend, your APIs are written as typed functions. On the frontend, your API calls will type check your function calls. In your IDE, your calls are fully typed and has autocompletion.

There are powerful options that utilizes tRPC like Create T3 App. With T3, you get more functionality but you also get a lot of files that are not necessary to get tRPC working. Furthermore, T3 is only meant to create a new project. I’m going to walk through a basic setup of tRPC in an existing Next.js project.

1. Installing tRPC

We need to install the required tRPC packages and a couple support packages. Tanstack Query is a library that simplifies data-fetching. Zod is a TypeScript-first schema declaration and validation library that integrates well with tRPC.

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

2. Enable Strict Mode

To use Zod for validation, add the following to tsconfig.json:

"compilerOptions": {
+  "strict": true
}

3. File structure overview

Here is the recommended file structure for our Next.js project. I have a GitHub repo with all the files here.

.
β”œβ”€β”€ src
β”‚ β”œβ”€β”€ layout.tsx # <-- add Provider here
β”‚ β”œβ”€β”€ page.tsx # <-- make API call here
β”‚ β”œβ”€β”€ app
β”‚ β”‚ β”œβ”€β”€ api
β”‚ β”‚ β”‚ └── trpc
β”‚ β”‚ β”‚ └── [trpc]
β”‚ β”‚ β”‚ └── route.ts # <-- tRPC HTTP handler
β”‚ β”‚ └── [..]
β”‚ β”œβ”€β”€ server
β”‚ β”‚ β”œβ”€β”€ index.ts # <-- main app router
β”‚ β”‚ └── trpc.ts # <-- procedure helpers
β”‚ └── utils
β”‚   β”œβ”€β”€ Provider.ts # <-- create app context
β”‚   └── trpc.ts # <-- your type safe tRPC hooks
└── [..]

4. Create server files

server/trpc.ts

import { initTRPC } from "@trpc/server";

// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create();

// Base router and procedure helpers
export const { router, procedure } = t;

server/index.ts

Define your API routes here. I have a basic hello route that takes a string and returns a greeting.

import { z } from "zod";
import { procedure, router } from "@/server/trpc";

export const appRouter = router({
  hello: procedure
    .input(
      z.object({
        text: z.string(),
      })
    )
    .query((opts) => {
      return {
        greeting: `hello ${opts.input.text}`,
      };
    }),
});

// export type definition of API
export type AppRouter = typeof appRouter;

5. Create tRPC helpers and context provider

utils/trpc.ts

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

function getBaseUrl() {
  if (typeof window !== "undefined")
    // browser should use relative path
    return "";

  if (process.env.VERCEL_URL)
    // reference for vercel.com
    return `https://${process.env.VERCEL_URL}`;

  if (process.env.RENDER_INTERNAL_HOSTNAME)
    // reference for render.com
    return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;

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

export const trpc = createTRPCReact < AppRouter > {};

utils/Provider.tsx

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

import { trpc } from "@/utils/trpc";
function getBaseUrl() {
  if (typeof window !== "undefined")
    // browser should use relative path
    return "";

  if (process.env.VERCEL_URL)
    // reference for vercel.com
    return `https://${process.env.VERCEL_URL}`;

  if (process.env.RENDER_INTERNAL_HOSTNAME)
    // reference for render.com
    return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;

  // assume localhost
  return `http://localhost:${process.env.PORT ?? 3000}`;
}
export default function Provider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({}));
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

Add the new Provider to your layout so that page.tsx can make API calls.

import type { Metadata } from "next";
import Provider from "@/utils/Provider";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode,
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Provider>{children}</Provider>
      </body>
    </html>
  );
}

6. Create tRPC HTTP handlers

api/trpc/[trpc]/route.ts

import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server";

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

export { handler as GET, handler as POST };

7. Make an API call

With tRPC, instead of making a fetch call, we make a function call to the API.

"use client";
import { trpc } from "@/utils/trpc";

export default function Home() {
  const hello = trpc.hello.useQuery({ text: "client" });

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-4xl font-extrabold">Next.js + tRPC = 😎</h1>
      <section className="mt-10 text-xl text-[#bada55]">
        {!hello.data ? <p>Loading…</p> : <p>{hello.data.greeting}</p>}
      </section>
    </main>
  );
}

Here we are calling our hello API route with the text, β€œclient”. Before we get a response, our page shows the β€œLoading…” copy. As soon as we get our response, it’ll output the greeting data. Since our API was written with TypeScript, we will get warnings as soon as we are doing something wrong. For example, if you made this call trpc.hello.useQuery({text: 45}) with an integer, you’ll receive a clear error.

Type error

Another benefit you get with having your API procedure type safe is that when you hover over our route, hello, in the function call, you will see a popover with the route properties including the inputs and their types.

Route properties

One last thing, when you Option-click on the route name, hello, on the client side, you will jump to the file that contains the procedure definition. This makes it very convenient when you are implementing or working on the API.

Conclusion

tRPC offers a compelling type safe alternative to traditional API communication methods like REST and GraphQL. Its integration with Next.js leverages the full power of TypeScript, providing a seamless development experience with features like auto-completion and immediate type-checking. By following the steps outlined above, you can easily set up tRPC in your Next.js project and start enjoying the benefits of this modern approach to API design and consumption. The end-to-end type safety ensures that both your client and server code are robust and maintainable, making your development process more efficient and error-proof.

Resources