Next.js and TRPC: Streamlined Backend Communication with appRouter
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.
- Automatic typesafety: If you made a server side change, TypeScript will warn you of the errors directly in your client side
- Snappy DX: tRPC has no build or compile steps
- 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:
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
Now we you can navigate to localhost:3001
, and can see the result:
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:
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:
If you click add todo and refresh, this is what you get:
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.
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.
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:
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.