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 ๐: 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 thisnodeLinker: 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 ๐.
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.