Let's Build a Full-Stack App with tRPC and Next.js 14
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.
1mkdir 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.
1mkdir 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:
1yarn add typescript tsx
tsx
- Used to run typescript directly in nodejs
without the need to compile to javascript.
1yarn add --dev @types/node
Create tsconfig.json
and copy this.
1// tsconfig.json 2{ 3 "compilerOptions": { 4 /* Language and Environment */ 5 "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 6 "lib": [ 7 "ESNext" 8 ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 9 10 /* Modules */ 11 "module": "ESNext" /* Specify what module code is generated. */, 12 "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 13 "rootDir": "src" /* Specify the root folder within your source files. */, 14 "outDir": "dist" /* Specify an output folder for all emitted files. */, 15 "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 16 "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 17 18 /* Type Checking */ 19 "strict": true /* Enable all strict type-checking options. */, 20 "skipLibCheck": true /* Skip type checking all .d.ts files. */ 21 }, 22 "include": ["src/**/*"], 23 "exclude": ["node_modules"] 24}
Project setup
Install deps:
1yarn add @trpc/server cors dotenv express superjson zod
1yarn add --dev @types/cors @types/express nodemon cross-env
Open package.json
and add these.
1{ 2 "scripts": { 3 "build": "NODE_ENV=production tsc", 4 "dev": "cross-env NODE_ENV=development nodemon --watch '**/*.ts' --exec node --import tsx/esm src/index.ts", 5 "start": "node --import tsx/esm src/index.ts" 6 }, 7 "type": "module", 8 "main": "src/index.ts", 9 "files": [ 10 "dist" 11 ] 12}
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:
1// src/trpc.ts 2 3import { initTRPC, type inferAsyncReturnType } from "@trpc/server"; 4import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; 5import SuperJSON from "superjson"; 6 7/** 8 * Creates the context for tRPC by extracting the request and response objects from the Express context options. 9 */ 10export const createContext = ({ 11 req, 12 res, 13}: CreateExpressContextOptions) => ({}); 14export type Context = inferAsyncReturnType<typeof createContext>; 15 16/** 17 * The tRPC instance used for handling server-side requests. 18 */ 19const t = initTRPC.context<Context>().create({ 20 transformer: SuperJSON, 21}); 22 23/** 24 * The router object for tRPC. 25 */ 26export const router = t.router; 27/** 28 * Exported constant representing a tRPC public procedure. 29 */ 30export 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.
1// src/routes.ts 2 3import { publicProcedure, router } from "./trpc"; 4 5const appRouter = router({ 6 test: publicProcedure.query(() => { 7 return "Hello, world!"; 8 }), 9}); 10 11export default appRouter;
Here, test
is a query procedure. It's like GET
a request.
Now, let's create index.ts
to create express app.
1// src/index.ts 2 3import { createExpressMiddleware } from "@trpc/server/adapters/express"; 4import cors from "cors"; 5import "dotenv/config"; 6import type { Application } from "express"; 7import express from "express"; 8import appRouter from "./routes"; 9import { createContext } from "./trpc"; 10 11const app: Application = express(); 12 13app.use(cors()); 14 15app.use("/health", (_, res) => { 16 return res.send("OK"); 17}); 18 19app.use( 20 "/trpc", 21 createExpressMiddleware({ 22 router: appRouter, 23 createContext, 24 }) 25); 26 27app.listen(process.env.PORT, () => { 28 console.log(`✅ Server running on port ${process.env.PORT}`); 29}); 30 31export 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:
1{ 2 "result": { 3 "data": { 4 "json": "Hello, world!" 5 } 6 } 7}
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:
1yarn create next-app@latest
1What is your project named? frontend 2Would you like to use TypeScript? Yes 3Would you like to use ESLint? No 4Would you like to use Tailwind CSS? Yes 5Would you like to use `src/` directory? Yes 6Would you like to use App Router? (recommended) Yes 7Would you like to customize the default import alias (@/*)? No 8What import alias would you like configured? @/*
Move into the folder:
1cd 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)
1yarn 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:
1// src/utils 2 3import { createTRPCReact } from "@trpc/react-query"; 4import { AppRouter } from "../../../backend/src/index"; 5 6export 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:
1// src/lib/providers/trpc.tsx 2 3"use client"; 4 5import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 6import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 7import { httpBatchLink, loggerLink } from "@trpc/client"; 8import superjson from "superjson"; 9 10import { trpc } from "@/utils/trpc"; 11 12if (!process.env.NEXT_PUBLIC_TRPC_API_URL) { 13 throw new Error("NEXT_PUBLIC_TRPC_API_URL is not set"); 14} 15 16export const queryClient = new QueryClient({ 17 defaultOptions: { 18 queries: { 19 staleTime: 1000 * 60 * 2, 20 refetchOnWindowFocus: false, 21 retry: false, 22 }, 23 }, 24}); 25 26const trpcClient = trpc.createClient({ 27 transformer: superjson, 28 links: [ 29 loggerLink({ 30 enabled: () => process.env.NODE_ENV === "development", 31 }), 32 httpBatchLink({ 33 url: process.env.NEXT_PUBLIC_TRPC_API_URL, 34 }), 35 ], 36}); 37 38export function TRPCProvider({ 39 children, 40}: Readonly<{ children: React.ReactNode }>) { 41 return ( 42 <trpc.Provider client={trpcClient} queryClient={queryClient}> 43 <QueryClientProvider client={queryClient}> 44 {children} 45 <ReactQueryDevtools position="bottom-left" /> 46 </QueryClientProvider> 47 </trpc.Provider> 48 ); 49}
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:
1// src/lib/providers/index.tsx 2 3import { TRPCProvider } from "./trpc"; 4 5export function Providers({ 6 children, 7}: Readonly<{ children: React.ReactNode }>) { 8 return <TRPCProvider>{children}</TRPCProvider>; 9}
Finally, wrap the app with providers, the best place to do this is in the root layout:
1// src/app/layout.tsx 2 3import { Providers } from "@/lib/providers"; 4import type { Metadata } from "next"; 5import { Inter } from "next/font/google"; 6import "./globals.css"; 7 8const inter = Inter({ subsets: ["latin"] }); 9 10export const metadata: Metadata = { 11 title: "Finance Tracker", 12 description: "Track your finances", 13}; 14 15export default function RootLayout({ 16 children, 17}: Readonly<{ 18 children: React.ReactNode; 19}>) { 20 return ( 21 <html lang="en"> 22 <body className={inter.className}> 23 <Providers>{children}</Providers> 24 </body> 25 </html> 26 ); 27}
Let's test the integration, edit page.tsx
:
1// src/app/page.tsx 2 3"use client"; 4 5import { trpc } from "@/utils/trpc"; 6 7export default function Home() { 8 const { data } = trpc.test.useQuery(); 9 10 return ( 11 <main className="flex min-h-screen flex-col items-center justify-between p-24"> 12 {data} 13 </main> 14 ); 15}
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.
LEAVE A COMMENT OR START A DISCUSSION
MORE ARTICLES
3 min read
Introducing Publish Studio: Power Digital Content Creation
Say “Hi” to Publish Studio, a platform I’ve building for the past few months. If you are a content writer, then you should definitely check it out. And if you are someone who has an audience on multiple blogging platforms and need an easy way to manage your content across platforms, then you should 100% give it a try.
14 min read
Dockerizing Your MERN Stack App: A Step-by-Step Guide
Are you tired of spending hours messing with crontabs and installing packages in an attempt to run your app locally? Are you sick of always missing a dependency that doesn't allow you to run the app and therefore you have to debug it for hours trying to find what's wrong? Then you've come to the right place. In this article, you will learn how to make use of Docker to develop and ship your software faster and easier.