Let's Build a Full-Stack App with tRPC and Next.js App router

Rakesh PotnuruRakesh Potnuru
9 min read

Published from Publish Studio

Are you a typescript nerd looking to up your full-stack game? Then this guide is for you. The traditional way to share types of your API endpoints is to generate schemas and share them with the front end or other servers. However, this can be a time-consuming and inefficient process. What if I tell you there's a better way to do this? What if I tell you, you can just write the endpoints and your frontend automatically gets the types?

๐Ÿฅ Let me introduce you to tRPC - a better way to build full-stack typescript apps.

What is tRPC?

To understand tRPC, it's important to first know about RPC (Remote Procedure Call). RPC is a protocol for communication between two services, similar to REST and GraphQL. With RPC, you directly call procedures (or functions) from a service.

tRPC stands for TypeScript RPC. It allows you to directly call server functions from the client, making it faster and easier to connect your front end to your back end.

tRPC in action

(tRPC in action ๐Ÿ‘†: source)

As you can see, you immediately get feedback from the client as you edit the endpoint.

Things I like about tRPC:

  • It's like an SDK - you directly call functions.

  • Perfect for monorepos.

  • Autocompletion

  • and more...

Let's build a full-stack application to understand tRPC capabilities.

Note: To use tRPC both your server and client should be in the same directory (and repo).

The Project

We are going to build a Personal Finance Tracker app to track our income, and expenses and set goals. I will divide this guide into a series to keep it interesting. Today, let's setup the backend (tRPC with ExpressJs adapter), and front end (Next.Js app router).

Start off by creating a folder for the project.

mkdir finance-tracker && cd finance-tracker

Backend - tRPC with ExpressJs adapter

You can use tRPC standalone adapter but if you like to use a server framework, tRPC has adapters for most of them. Note that tRPC is a communication protocol (like REST) and not a server.

Create a folder called server inside the project folder and initialize the project.

mkdir backend && cd backend && yarn init

Setup Typescript

Note: If you are using yarn v4 you have to create yarnrc.yml and put this nodeLinker: node-modules.

Install deps:

yarn add typescript tsx

tsx - Used to run typescript directly in nodejs without the need to compile to javascript.

yarn add --dev @types/node

Create tsconfig.json and copy this.

// tsconfig.json
{
    "compilerOptions": {
        /* Language and Environment */
        "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
        "lib": [
            "ESNext"
        ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,

        /* Modules */
        "module": "ESNext" /* Specify what module code is generated. */,
        "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
        "rootDir": "src" /* Specify the root folder within your source files. */,
        "outDir": "dist" /* Specify an output folder for all emitted files. */,
        "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,

        /* Type Checking */
        "strict": true /* Enable all strict type-checking options. */,
        "skipLibCheck": true /* Skip type checking all .d.ts files. */
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules"]
}

Project setup

Install deps:

yarn add @trpc/server cors dotenv express superjson zod
yarn add --dev @types/cors @types/express nodemon cross-env

Open package.json and add these.

{
    "scripts": {
        "build": "NODE_ENV=production tsc",
        "dev": "cross-env NODE_ENV=development nodemon --watch '**/*.ts' --exec node --import tsx/esm src/index.ts",
        "start": "node --import tsx/esm src/index.ts"
    },
    "type": "module",
    "main": "src/index.ts",
    "files": [
        "dist"
    ]
}

Create .env

PORT=4000

Add these to .gitignore

node_modules/
dist
.env

Let's start by creating trpc.ts inside src. This is the bare minimum required to create API endpoints with tRPC:

// src/trpc.ts

import { initTRPC, type inferAsyncReturnType } from "@trpc/server";
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import SuperJSON from "superjson";

/**
 * Creates the context for tRPC by extracting the request and response objects from the Express context options.
 */
export const createContext = ({
  req,
  res,
}: CreateExpressContextOptions) => ({});
export type Context = inferAsyncReturnType<typeof createContext>;

/**
 * The tRPC instance used for handling server-side requests.
 */
const t = initTRPC.context<Context>().create({
  transformer: SuperJSON,
});

/**
 * The router object for tRPC.
 */
export const router = t.router;
/**
 * Exported constant representing a tRPC public procedure.
 */
export const publicProcedure = t.procedure;

First, we created tRPC context which can be accessed by routes. You can pass whatever you want to share with your routes. The best example will be passing user object, so we can access user information in routes.

Next, we initialized tRPC with initTRPC. In this, we can use a transformer like superjson - which is used to serialize JS expressions. For example, if you pass Date, it will be inferred as Date instead of string. So it's perfect for tRPC since we tightly couple frontend and backend.

After that, we defined a router with which we can create endpoints.

Finally, we created a reusable procedure called publishProcedure. procedure in tRPC is just a function that the frontend can access. Think of them like endpoints. The procedure can be a Query, Mutation, or Subscription. Later we will create another reusable procedure called protectedProcedure that will allow only authorized user access to certain endpoints.

Let's create an endpoint. I like to keep all the routes inside a dedicated routes file. So create routes.ts and create an endpoint.

// src/routes.ts

import { publicProcedure, router } from "./trpc";

const appRouter = router({
  test: publicProcedure.query(() => {
    return "Hello, world!";
  }),
});

export default appRouter;

Here, test is a query procedure. It's like GET a request.

Now, let's create index.ts to create express app.

// src/index.ts

import { createExpressMiddleware } from "@trpc/server/adapters/express";
import cors from "cors";
import "dotenv/config";
import type { Application } from "express";
import express from "express";
import appRouter from "./routes";
import { createContext } from "./trpc";

const app: Application = express();

app.use(cors());

app.use("/health", (_, res) => {
  return res.send("OK");
});

app.use(
  "/trpc",
  createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

app.listen(process.env.PORT, () => {
  console.log(`โœ… Server running on port ${process.env.PORT}`);
});

export type AppRouter = typeof appRouter;

If you have worked with ExpressJs before, then you should be familiar with this code. We created a server and exposed it on a specified PORT. To expose tRPC endpoints to the express app, we can use createExpressMiddleware function from the tRPC express adapter.

Now, we can access all the routes we are going to create in routes.ts from base endpoint /trpc.

To test it out, start the server by running yarn dev and go to http://localhost:4000/trpc/test. You will see the output:

{
  "result": {
    "data": {
      "json": "Hello, world!"
    }
  }
}

That's it, our backend is ready. Now let's create a frontend with Next.Js to consume the endpoint we just created.

Frontend - Next.Js with App Router

Go back to the project root and create a Next.Js project with these settings:

yarn create next-app@latest
What is your project named? frontend
Would you like to use TypeScript? Yes
Would you like to use ESLint? No
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? No
What import alias would you like configured? @/*

Move into the folder:

cd frontend

The final folder structure will be:

.
โ””โ”€โ”€ finance-tracker/
    โ”œโ”€โ”€ frontend
    โ””โ”€โ”€ backend

Let's integrate tRPC in our frontend:

Install deps: (make sure to add yarnrc.yml file)

yarn add @trpc/react-query superjson zod @trpc/client @trpc/server @tanstack/react-query@4.35.3 @tanstack/react-query-devtools@4.35.3

tRPC is a wrapper around react-query, so you can use all your favorite features from react-query.

First, put the backend url in env. Create .env.local and put:

NEXT_PUBLIC_TRPC_API_URL=http://localhost:4000/trpc

Create tRPC react client:

// src/utils

import { createTRPCReact } from "@trpc/react-query";
import { AppRouter } from "../../../backend/src/index";

export const trpc = createTRPCReact<AppRouter>();

Here, we created a tRPC client for react with createTRPCReact provided by tRPC and imported from previously created AppRouter which contains all routes information.

Now, let's create a tRPC provider, so our whole app can access the context, I prefer to put all the providers in one place to make the entry file more readable:

// src/lib/providers/trpc.tsx

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { httpBatchLink, loggerLink } from "@trpc/client";
import superjson from "superjson";

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

if (!process.env.NEXT_PUBLIC_TRPC_API_URL) {
  throw new Error("NEXT_PUBLIC_TRPC_API_URL is not set");
}

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 2,
      refetchOnWindowFocus: false,
      retry: false,
    },
  },
});

const trpcClient = trpc.createClient({
  transformer: superjson,
  links: [
    loggerLink({
      enabled: () => process.env.NODE_ENV === "development",
    }),
    httpBatchLink({
      url: process.env.NEXT_PUBLIC_TRPC_API_URL,
    }),
  ],
});

export function TRPCProvider({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
        <ReactQueryDevtools position="bottom-left" />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

If you are familiar with react-query, then this is not new to you. As an extra step, we just wrapped QueryClientProvider with the tRPC provider.

Similar to the backend we are using superjson as the transformer. loggerLink helps us debug network requests directly in the console. You can learn more about links the array here.

Create index.tsx inside providers to export all the individual providers from a single file:

// src/lib/providers/index.tsx

import { TRPCProvider } from "./trpc";

export function Providers({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return <TRPCProvider>{children}</TRPCProvider>;
}

Finally, wrap the app with providers, the best place to do this is in the root layout:

// src/app/layout.tsx

import { Providers } from "@/lib/providers";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

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

export const metadata: Metadata = {
  title: "Finance Tracker",
  description: "Track your finances",
};

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

Let's test the integration, edit page.tsx:

// src/app/page.tsx

"use client";

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

export default function Home() {
  const { data } = trpc.test.useQuery();

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      {data}
    </main>
  );
}

Here, we imported the trpc client that we created above and you get all the auto completions from it.

If you start both backend and frontend and go to http://localhost:3000 in your browser, you can see it working.

Now, if make any changes to your API, you get changes in the front end instantly. For example, change the spelling of test API to hello and see page.tsx throwing error. This ultimately improves your development experience and makes building typescript apps awesome.


This is all you need to get started with tRPC and Next.Js. In the next article, we'll integrate the database and create some CRUD operations.


Project source code can be found here.


Follow for more ๐Ÿš€.

Socials

0
Subscribe to my newsletter

Read articles from Rakesh Potnuru directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rakesh Potnuru
Rakesh Potnuru

I'm Rakesh Potnuru, a Full Stack Developer, Technical Writer, and passionate learner. I am a B.Tech graduate in Computer Science and Engineering at Lovely Professional University, Punjab, India. I have worked on a wide range of technologies and projects ranging from small to large scale. I am a self-motivated and self-driven individual who is always looking for new challenges and opportunities. I love participating in hackathons and engaging in communities.