React trpc

Type safe backend-frontend communication.

tRPC is by far my favorite way for communicating with my server in my react applications.
In this lesson we will learn how to integrate React application with tRPC, we will create a monorepository using TURBOREPO and that monorepo will contain our backend server and react frontend application.
Using trpc we will communicate between React and our backend.

To use tRPC you will have to use Typescript, it's also recommended to use if you are using monorepo to place all your code in a single place.
For monorepo management we recommend NX or TURBOREPO, in this tutorial we will use TURBOREPO

Create a TURBOREPO monorepository

In your terminal run the following command:

> npx create-turbo@latest

When asked choose npm as your package manager (you can choose something else but this article will write the installation commands in npm).
After your monorepo is created open the created folder with your IDE.
Notice that you have 2 Next application in the apps directory: web, docs we can delete the docs since we will only work with the apps/web.
You can run the dev server of apps/web using the command:

> npx turbo dev

You can visit the browser at http://localhost:3000 to view the app.

Create the trpc server

Let's create the backend server.
create the directory /apps/server and in it create a package.json file:

{
	"name": "server"
}
Read-only

Let's install the trpc packages in our server.

> npm i @trpc/server --workspace=server

Our server will have 3 files:

  • trpc.ts will reexport trpc objects
  • app.router.ts - will contain our trpc api
  • main.ts will start our server

Let's start with apps/server/trpc.ts

apps/server/trpc.ts
import {initTRPC} from '@trpc/server';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;
Read-only

We are initializing trpc and exposing router which is used to logically group api's and procedure which is used to create a query or mutation api.

Now let's create our api, in apps/server/app.router.ts

apps/server/app.router.ts
import {router, publicProcedure} from './trpc';

export const appRouter = router({
  hello: publicProcedure.query(() => 'hello world'),
});

export type AppRouter = typeof appRouter;
Read-only

We will start with a simple hello world and move from there.

Let's create the entry point for our server apps/server/main.ts

apps/server/main.ts
import {createHTTPServer} from '@trpc/server/adapters/standalone';
import {appRouter} from './app.router';

const server = createHTTPServer({
  router: appRouter,
});

server.listen(3001);
Read-only

Let's create a dev command in the apps/server/package.json for running the main server:

{
	"name": "server",
	"version": "0.0.1",
	"scripts": {
		"dev": "ts-node main.ts"
	},
	"dependencies": {
		"@trpc/server": "^10.45.0"
	}
}
Read-only

Now we can run both our frontend and backend with the command:

> npx turbo dev

after you run this command you can see our hello query running in http://localhost:3001/hello

cors

Let's open our server for cross origin requests in order to serve the frontend app running on port 3000 while the server is running on a different port 3001.
Usually you will either place the backend on the same domain but in different path, or on a subdomain and restrict cors in a better way then what we will do here - here it's the quick and dirty open cors to everyone, not good for production but for development and this introduction tutorial it will be sufficient.

> npm i cors --workspace=server
> npm i @types/cors --workspace=server
apps/server/main.ts
import {createHTTPServer} from '@trpc/server/adapters/standalone';
import {appRouter} from './app.router';
import cors from 'cors';

const server = createHTTPServer({
  middleware: cors(),
  router: appRouter,
});

server.listen(3001);
Read-only

Frontend

time to make our first frontend request, in this lesson we won't focus on the technicallities of server side rendering, but if that intrests you you should follow the nextjs trpc guide.
In our case we will not focus on nextjs and focus on the React part.

Install the client packages:

> npm install @trpc/client @trpc/server @trpc/react-query @tanstack/react-query@4 --workspace=web

Note that one of the packages we installed is @tanstack/react-query, This is a big advantage of using trpc for our React application, it works on top of react-query so you're not losing all the great features of react-query but are getting all of them and in addition static type checking to your client-server communications.
So basically we need to initialize our trpc and react-query providers, but before we do that let's modify our apps/web/package.json that we are using the type AppRouter defined in the server, this is how our client will not the exact api's that are exposed in the server.

apps/web/package.json

{
  "name": "web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint . --max-warnings 0"
  },
  "dependencies": {
    "@repo/ui": "*",
    "@tanstack/react-query": "^4.36.1",
    "@trpc/client": "^10.45.0",
    "@trpc/react-query": "^10.45.0",
    "@trpc/server": "^10.45.0",
    "next": "^14.0.4",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
		"server": "*"
  },
  "devDependencies": {
    "@next/eslint-plugin-next": "^14.0.4",
    "@repo/eslint-config": "*",
    "@repo/typescript-config": "*",
    "@types/eslint": "^8.56.1",
    "@types/node": "^20.10.6",
    "@types/react": "^18.2.46",
    "@types/react-dom": "^18.2.18",
    "eslint": "^8.56.0",
    "typescript": "^5.3.3"
  }
}
Read-only

Notice that we added "server": "*" to tell turborepo that we need to import from apps/server (this should be the same name we wrote in the apps/server/package.json).
Let's make sure that the server is exporting the type AppRouter.
Change the file apps/server/package.json to the following:

{
	"name": "server",
	"version": "0.0.1",
	"scripts": {
		"dev": "ts-node main.ts"
	},
	"dependencies": {
		"@trpc/server": "^10.45.0",
		"@types/cors": "^2.8.17",
		"cors": "^2.8.5"
	},
	"exports": {
		"./app.router": "./app.router.ts"
	}
}
Read-only

Notice that we added the exports part to specify that we are exporting the type in app.router.ts.
Now we can start using that type in our apps/web frontend.

Let's initialize trpc in the client which will allow us to create the client as well as issue requests to the server using trpc and react-query.
Create the file: apps/web/app/trpc.ts with the following:

apps/web/app/trpc.ts
import {createTRPCReact} from '@trpc/react-query';
import type {AppRouter} from 'server/app.router';

export const trpc = createTRPCReact<AppRouter>();
Read-only

The best place to initialize all the providers (trpc and react-query) is in our layout which will make it available everywhere. Edit the file apps/web/app/layout.tsx

apps/web/app/layout.tsx
'use client';

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

export default function RootLayout({children}: {children: React.ReactNode}): JSX.Element {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3001',
        }),
      ],
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <html lang="en">
          <body>{children}</body>
        </html>
      </QueryClientProvider>
    </trpc.Provider>
  );
}
Read-only

I had to also add baseUrl to the root of the monorepo for typescript to not complain about the trpc.ts file, so my apps/web/tsconfig.json looks like so:

{
  {
    "extends": "@repo/typescript-config/nextjs.json",
    "compilerOptions": {
      "baseUrl": "../..",
      "plugins": [
        {
          "name": "next"
        }
      ]
    },
    "include": [
      "next-env.d.ts",
      "next.config.js",
      "**/*.ts",
      "**/*.tsx",
      ".next/types/**/*.ts"
    ],
    "exclude": [
      "node_modules"
    ]
  }
}
Read-only

Now we can start using trpc magic in our client side. Let's create an <Hello> component that will query the hello api we created in trpc

apps/web/app/hello.tsx
import {trpc} from './trpc';

export function Hello() {
  const {data, isLoading} = trpc.hello.useQuery();

  if (isLoading) return <div>Loading...</div>;

  return <div>{data}</div>;
}
Read-only

In your apps/web/app/page.tsx file, let's add the component we just created:

apps/web/app/page.tsx
'use client';

import {Hello} from './hello';

export default function Page() {
  return <Hello />;
}
Read-only

You should see query is sent and the hello message is displayed.

input, output and zod

Let's harness some more static powers and validations in our server query. Let's create a query that will accept an input and return an output. We will have trpc validate that we are passing the right types in the frontend.
For validation we will use zod so let's install it:

> npm i zod --workspace=server

In the app.router.ts file let's add another query, this query will search in a list of strings, The query will get as an input a search term and will search an array of strings and will return array of matches.

apps/server/app.router.ts
import {router, publicProcedure} from './trpc';
import {z} from 'zod';

const list = ['hello', 'world', 'lurem', 'ipsum', 'academeez', 'rules'];

export const appRouter = router({
  hello: publicProcedure.query(() => 'hello world'),
  search: publicProcedure
    .input(z.string())
    .output(z.array(z.string()))
    .query(({input}) => {
      return list.filter(item => item.includes(input));
    }),
});

export type AppRouter = typeof appRouter;
Read-only

We added a new query called search which accepts a string as an input and returns an array of strings.
Notice that you can specify trpc an input and an output, the output makes sure that the server is returning the right type and the input makes sure that the client is sending the right type. The output will also verify that no extra fields are returned from the server (good for security).

Let's create a component that will use this query, create the file apps/web/app/search.tsx

apps/web/app/search.tsx
import {useState} from 'react';
import {trpc} from './trpc';

export function Search() {
  const [search, setSearch] = useState('');
  const {data, isLoading} = trpc.search.useQuery(search);

  return (
    <>
      <input type="search" value={search} onChange={e => setSearch(e.target.value)} />
      {isLoading && <div>Loading...</div>}
      {data && (
        <ul>
          {data.map(str => (
            <li key={str}>{str}</li>
          ))}
        </ul>
      )}
    </>
  );
}
Read-only

We have an input to type the search string, and a ul-li to display the result, notice that data type is defined for us as string[] according to the output we specified in the server, notice also that typescript will verify that you supply an input and the type of the input.

Let's place that component on screen, edit the file apps/web/app/page.tsx

apps/web/app/page.tsx
'use client';

import {Hello} from './hello';
import {Search} from './search';

export default function Page() {
  return (
    <>
      <Hello />
      <Search />
    </>
  );
}
Read-only

You should see a search list with input and the server returning a list.

mutations

Let's create a mutation that will add a string to the list, we will use the same list we used in the search query.

apps/server/app.router.ts
...
export const appRouter = router({
  hello: publicProcedure.query(() => 'hello world'),
  search: publicProcedure
    .input(z.string())
    .output(z.array(z.string()))
    .query(({input}) => {
      return list.filter(item => item.includes(input));
    }),
  add: publicProcedure
    .input(z.string())
    .output(z.string())
    .mutation(({input}) => {
      list.push(input);
      return input;
    }),
});
...
Read-only

notice that we now have an add mutation that will get a string input and return the string output and push the string to the list.

Let's create a component that will use this mutation, create the file apps/web/app/add.tsx

apps/web/app/add.tsx
import {FormEvent, useState} from 'react';
import {trpc} from './trpc';

export function Add() {
  const [item, setItem] = useState('');
  const {mutate, isSuccess} = trpc.add.useMutation();

  const handleSubmit = (e: FormEvent) => {
    mutate(item);
    e.preventDefault();
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input type="text" value={item} onChange={e => setItem(e.target.value)} />
        <button type="submit">Add</button>
        {isSuccess && <div>Item added!</div>}
      </form>
    </div>
  );
}
Read-only

Notice that we are using useMutation instead of useQuery since this is a mutation and not a query. The mutate function will send the mutation to the server and the isSuccess will be true when the mutation is successful, also the mutate will validate that we are sending the right input.

Let's place that component on screen, edit the file apps/web/app/page.tsx

apps/web/app/page.tsx
'use client';

import {Hello} from './hello';
import {Search} from './search';
import {Add} from './add';

export default function Page() {
  return (
    <>
      <Hello />
      <Search />
      <Add />
    </>
  );
}
Read-only

Summary

I've been using trpc for a while now, and I have to say that it's my favorite way to communicate between my frontend and backend. On my large projects with large teams, that extra staticness in frontend backend communication really reduces the amount of bugs, before trpc the layer of communication between backend and frontend always was prune to bugs, the client might expect a certain response and gettings something different while the server expects a certain input and gets something else, the amount of those bugs are drastically reduced with trpc, not that it's clear and enforced by typescript that the backend frontend communication is done properly.
There is a caveat that it works better if the frontend projects and backend projects sits together in a mono repo, the other alternative would require the server project to publish the app router type (with npm publish for example) which would turn things a bit more clumsy in my opinion. But if you are working in a mono repo, this static layer on your back-front communication would do wonders to your project.

The full source code is available here