Next.js and TRPC: Streamlined Backend Communication with appRouter

Prajeet Kumar Thakur
readytowork, Inc.
Published in
9 min readApr 24, 2024

Introduction

tRPC (Typescript Remote Procedure Call) simplifies API definition by directly leveraging TypeScript endpoints. It stands out as one of the most straightforward and lightweight libraries for executing backend functions remotely on the client side. Embracing tRPC offers numerous advantages, ultimately leading to more robust and maintainable applications.

  1. Automatic typesafety: If you made a server side change, TypeScript will warn you of the errors directly in your client side
  2. Snappy DX: tRPC has no build or compile steps
  3. Framework agnostic: Compatible with all frameworks

and many more.

In this tutorial, we’ll build a todoList application in Next.js, integrating tRPC with SQLite for the backend. Our focus is on demonstrating how to set up tRPC with Next.js and highlight its benefits, rather than developing a complex application. By choosing a simple todoList app, we aim to provide a clear and concise guide for leveraging tRPC in your projects.

Creating our application

First of all create a next js application with typescript, src directory, and tailwind using your terminal:

yarn create next-app my-next-app

Then, add the tRPC server package:

yarn add @trpc/server

Then add these following packages:

yarn add @trpc/client @trpc/react-query @tanstack/react-query

Initializing tRPC

Inside the src folder, create a new directory named server, and create a file trpc.ts and type the following code:

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

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;

This code sets up a custom request handler for tRPC in a Next.js application, using fetchRequestHandler to process HTTP requests and route them to the correct tRPC procedures. It's configured with an endpoint, the request, the tRPC router, and a context creation function. This enables type-safe server-client communication in the application.

Now create another file in that directory named index.ts

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

export const appRouter = router({
getTodos: publicProcedure.query(async () => {
return [10, 20, 30];
}),
});

export type AppRouter = typeof appRouter;

This creates a router instance called appRouter using functions imported from “tRPC”. It defines a single public query procedure getTodos, which asynchronously returns an array [10, 20, 30].

Now create a folder in app directory as shown in the screenshot below:

Fig: Shorthand for creating multiple nested directories and file

Now add the following code in route.ts:

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

import { appRouter } from "@/server";

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

export { handler as GET, handler as POST };

This TypeScript code exports a handler function that serves both GET and POST requests. It utilizes fetchRequestHandler from the “@trpc/server/adapters/fetch” package to handle incoming requests. The handler is set up to listen at the “/api/trpc” endpoint. It utilizes the `appRouter` imported from “@/server” to handle routing and processing of requests. Additionally, it defines a createContext function, currently returning an empty object. This structure suggests the setup of an API endpoint for handling TRPC (TypeScript RPC) requests, commonly used for server-client communication in TypeScript-based applications.

Now you can check the functionality of your code by visiting the link: http://localhost:3001/api/trpc/getTodos

We can see that the numbers array is being displayed as intended.

Then, create a new directory inside app called _trpc and inside it create the client.ts file.

Note: any directory starting from _ such as _trpc will get ignored by the appRouter in terms of routing.

Add this code to the client.ts file

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

import { type AppRouter } from "@/server";

export const trpc = createTRPCReact<AppRouter>({});

This TypeScript code integrates TRPC into React using `createTRPCReact`. It configures a `tRPC` object with an `AppRouter` type from “@/server”. This enables smooth communication between the frontend and server-side API for data fetching and mutations in TypeScript applications.

Next, create a provider file in the _trpc directory and add the following code:

"use client"

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

export default function Provider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "http://localhost:3001/api/trpc",
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}

This code sets up a TRPC client and a React Query client. It initializes a QueryClient and a TRPC client configured with an HTTP batch link to “http://localhost:3001/api/trpc". These clients are then wrapped in providers for TRPC and React Query, facilitating data fetching and mutation handling in the React application.

Add this provider in the layout:

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

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

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

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

Then, create a new file within a new directory _components/TodoList.tsx inside the app directory:

"use client";

import { trpc } from "../_trpc/client";

export default function TodoList() {
const getTodos = trpc.getTodos.useQuery();
return (
<div>
<div>{JSON.stringify(getTodos.data)}</div>
</div>
);
}

This is the file that represents the client side of our application, and which will fetch the data from our backend.

Add this in the page.tsx file:

import TodoList from "./_components/TodoList";

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

At the time of making this article, use the following version of the dependencies, especially the @tanstack/react-query, i.e. version ^4.35.3

Fig: List of stable versions of packages to use

Now we you can navigate to localhost:3001, and can see the result:

Fig: Displaying the static data from trpc

Connecting to a database

Now to make this example a bit more real-world like, we will connect it to a database.

Install drizzle from the terminal:

yarn add drizzle-orm better-sqlite3

Sqlite does not have types, hence we need to install types for that:

yarn add @types/better-sqlite3 -D

Now, create a new directory inside app, named db/schema.ts. This defines our todos table

import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const todos = sqliteTable("todos", {
id: integer("id").primaryKey(),
content: text("content"),
done: integer("done"),
});

Add a drizzle.config.ts file on the top layer, inside the trpc-onAppRouter folder:

import type { Config } from "drizzle-kit";

export default {
schema: "./src/app/db/schema.ts",
out: "./drizzle",
driver: "better-sqlite",
dbCredentials: {
url: "sqlite.db",
},
} satisfies Config;

This code defines a configuration for Drizzle Kit, specifying a schema file location, output directory, database driver, and credentials for a SQLite database. It indicates compliance with the `Config` type from “drizzle-kit”.

Install drizzle-kit:

yarn add drizzle-kit

Now let’s run our first migration:

yarn drizzle-kit generate:sqlite

A new migration file and table has been created:

Fig: Migration file created and definition of todos table

Now we need to connect our database with trpc, in the server/index.ts:

import Database from "better-sqlite3";
import { publicProcedure, router } from "./trpc";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { todos } from "@/app/db/schema";

const sqlite = new Database("sqlite.db");
const db = drizzle(sqlite);

export const appRouter = router({
getTodos: publicProcedure.query(async () => {
return await db.select().from(todos).all();
}),
});

export type AppRouter = typeof appRouter;

To ensure validations from the frontend, install zod

yarn add zod

Edit the server/index.ts file

import Database from "better-sqlite3";
import { publicProcedure, router } from "./trpc";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { todos } from "@/app/db/schema";
import { z } from "zod";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";

const sqlite = new Database("sqlite.db");
const db = drizzle(sqlite);

migrate(db, { migrationsFolder: "drizzle" });

export const appRouter = router({
getTodos: publicProcedure.query(async () => {
return await db.select().from(todos).all();
}),
/*Added a This procedure to let users add todos*/
addTodo: publicProcedure.input(z.string()).mutation(async (opts) => {
await db.insert(todos).values({ content: opts.input, done: 0 }).run();
return true;
}),
});

export type AppRouter = typeof appRouter;

The index.ts file in the server directory of our Next.js project with tRPC integration initializes a tRPC router and defines a set of procedures for interacting with a SQLite database using Drizzle ORM. It sets up a database connection, performs database migrations, and exports an appRouter with procedures for fetching and adding todos. This setup allows your application to perform CRUD operations on a todos list in a type-safe manner, leveraging tRPC for server-client communication and Drizzle ORM for database interactions.

Now edit the TodoList.tsx

"use client";

import { useState } from "react";
import { trpc } from "../_trpc/client";

export default function TodoList() {
const getTodos = trpc.getTodos.useQuery();
const addTodo = trpc.addTodo.useMutation({
onSettled: () => {
getTodos.refetch();
},
});

const [content, setContent] = useState("");
return (
<div>
<div>{JSON.stringify(getTodos.data)}</div>
<div>
{getTodos?.data?.map((todo) => (
<div key={todo.id} className="flex gap-3 items-center">
<input
id={`check-${todo.id}`}
type="checkbox"
checked={!!todo.done}
style={{ zoom: 1.5 }}
/>
<label htmlFor={`check-${todo.id}`}>{todo.content}</label>
</div>
))}
</div>
<div>
<label htmlFor="content">Content: </label>
<input
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="text-black"
/>
<button
onClick={async () => {
if (content.length) {
addTodo.mutate(content);
setContent("");
}
}}
>
Add todo
</button>
</div>
</div>
);
}

This code defines a React component named TodoList responsible for displaying a list of todos and adding new todos. It utilizes TRPC (TypeScript RPC) hooks to fetch todos (getTodos) and add new todos (addTodo). The component renders the fetched todos as checkboxes with labels and provides an input field and a button for adding new todos. Upon adding a new todo, the component updates the list by refetching the todos.

And navigate to the localhost:3001:

Fig: Entering a todo item

If you click add todo and refresh, this is what you get:

Fig: Rendering of a todo item after clickng add todo and refreshing the page

Enhance styling and concluding development

To enhance the visual appeal of the page, let’s remove the existing styles in global.css and add the following code in TodoList.tsx

"use client";

import { useState } from "react";
import { trpc } from "../_trpc/client";

export default function TodoList() {
const getTodos = trpc.getTodos.useQuery();
const addTodo = trpc.addTodo.useMutation({
onSettled: () => {
getTodos.refetch();
},
});

const [content, setContent] = useState("");
return (
<div className="flex flex-col gap-3">
<h1 className="text-3xl">Todos</h1>
<div>
{getTodos?.data?.map((todo) => (
<div key={todo.id} className="flex gap-3 items-center">
<input
id={`check-${todo.id}`}
type="checkbox"
checked={!!todo.done}
style={{ zoom: 1.5 }}
/>
<label htmlFor={`check-${todo.id}`}>{todo.content}</label>
</div>
))}
</div>
<div>
<div className="text-2xl mb-2 mt-4">Add a todo</div>
<div className="flex gap-3 items-center">
<label htmlFor="content">Content: </label>
<input
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="text-black border-2 h-[40px]"
/>
<button
onClick={async () => {
if (content.length) {
addTodo.mutate(content);
setContent("");
}
}}
className="bg-blue-500 text-white p-2 rounded-lg"
>
Add todo
</button>
</div>
</div>
</div>
);
}

Our page layout should resemble the following screenshot. However, feel free to customize the styling according to your preferences.

Fig: Todos page after adding styling

Importance of tRPC

To underscore the significance of tRPC in this setup, we can navigate to the getTodos constant within TodoList.tsx. Here, we can directly access and inspect the types, highlighting the robust type inference capabilities provided by tRPC.

Fig: Types shown when using tRPC query

Imagine if we weren’t leveraging tRPC and instead relied on something like useEffect to fetch data. In such a scenario, our approach might look like this:

Fig: “any” type shown when calling without tRPC

Conclusion

With tRPC, type preservation occurs seamlessly from the schema definition through remote procedure calls, ensuring that types are maintained consistently. Unlike generic approaches where the return type might be ‘any’, tRPC retains the original types, providing clarity on the data structure fetched from the server. This preserves type safety throughout the application, from the schema to mutations and queries, enhancing development confidence and reducing potential errors.

Further progress

For this article, we’ll conclude here. However, if you wish to integrate additional type procedures such as setDone to enable checkboxes and more, you have the option to explore and implement them on your own. Alternatively, you can refer to this GitHub repository, which contains all the code written in this article up to this point and beyond.

References

  1. tRPC vs GraphQL: How to choose the best option for your next project
  2. Intro to tRPC: Integrated, full-stack TypeScript
  3. tRPC official documentation

--

--