DEV Community

Cover image for Let's Build a Full-Stack App with tRPC and Next.js App router
Rakesh Potnuru
Rakesh Potnuru

Posted on • Originally published at blog.itsrakesh.com

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

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

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

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

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

yarn add --dev @types/node
Enter fullscreen mode Exit fullscreen mode

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

Project setup

Install deps:

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

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

Create .env

PORT=4000
Enter fullscreen mode Exit fullscreen mode

Add these to .gitignore

node_modules/
dist
.env
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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
Enter fullscreen mode Exit fullscreen mode
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? @/*
Enter fullscreen mode Exit fullscreen mode

Move into the folder:

cd frontend
Enter fullscreen mode Exit fullscreen mode

The final folder structure will be:

.
โ””โ”€โ”€ finance-tracker/
    โ”œโ”€โ”€ frontend
    โ””โ”€โ”€ backend
Enter fullscreen mode Exit fullscreen mode

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

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

Create tRPC react client:

// src/utils

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

export const trpc = createTRPCReact<AppRouter>();
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Top comments (2)

Collapse
 
jeet_patel_7d186de1586420 profile image
Jeet Patel

Pretty good explanation, How can i use SSR. with this code is it possible to use SSR/RSC with useQuery.
and if it's possible please explain what and where do I need to change things.

Also you forgot to mention about protectedProcedure but it's fine since it's just function and once we have auth setup we can use ctx for check session/tokens and all. but please can you confirm SSR support and explain in comment if it's possible

Collapse
 
itsrakesh profile image
Rakesh Potnuru

I'm going to cover protectedProcedure in a later article.
Regarding ssr, tRPC doesn't have support for the app router yet. But if you use the pages router, you can set ssr=true like mentioned here.

For the app router, we have to create a server-client (this is also I'm going to cover in the authentication article, but here's a snippet from one of my projects):

import type { AppRouter } from "@publish-studio/core";
import type { HTTPHeaders } from "@trpc/react-query";
import {
  createTRPCProxyClient,
  getFetch,
  httpBatchLink,
  loggerLink,
} from "@trpc/react-query";
import superjson from "superjson";

export const createTRPCServerClient = (headers: HTTPHeaders) => {
  if (!process.env.NEXT_PUBLIC_TRPC_API_URL) {
    throw new Error("NEXT_PUBLIC_TRPC_API_URL is not set");
  }

  return createTRPCProxyClient<AppRouter>({
    transformer: superjson,
    links: [
      loggerLink({
        enabled: () => process.env.NODE_ENV === "development",
      }),
      httpBatchLink({
        url: process.env.NEXT_PUBLIC_TRPC_API_URL,
        headers() {
          return headers;
        },
        fetch: async (input, init?) => {
          const fetch = getFetch();
          return fetch(input, {
            ...init,
            credentials:
              process.env.NEXT_PUBLIC_TRPC_API_URL === "production"
                ? "include"
                : "omit",
          });
        },
      }),
    ],
  });
};
Enter fullscreen mode Exit fullscreen mode

then you can create a client and use query, mutation

const client = createTRPCServerClient({
        // headers
      });

const { data } = await client.myRoute.query();
Enter fullscreen mode Exit fullscreen mode

I hope this helps