Hono, RPC and React

A step-by-step guide to connecting a Hono backend with a React frontend using RPC.

September 30, 2025

You can see the full source code on GitHub.

For context, this tutorial extends the previous article Hono, Swagger, Pino.

Setup monorepo

We start from the previous project and transform it into a pnpm monorepo with a backend, a frontend, and a shared common package.

├── apps
│   ├── backend
│   ├── frontend
├── packages

mkdir -p apps/backend apps/frontend
mv package.json pnpm-lock.yaml README.md tsconfig.json src apps/backend
mkdir apps/frontend
cd apps/frontend
pnpm create vite
# Project name: frontend
# Select a framework: React
# Select a variant: TypeScript + React Compiler
cd ../..
mkdir -p packages/common
cd packages/common
pnpm init
cd ../.. pnpm init
touch pnpm-workspace.yaml

Declare the monorepo architecture in pnpm-workspace.yaml.

pnpm-workspace.yaml

packages:
  - apps/*
  - packages/*

Install dependencies for the whole workspace:

pnpm install

We will also need TanStack Query for React data fetching:

pnpm --filter backend add @tanstack/react-query

Shared package common

The common package contains shared code (e.g., constants, types) accessible by both backend and frontend.

├── package.json
├── src
│   ├── constants
│   │   ├── index.ts
│   │   └── role.ts
│   └── index.ts
└── tsconfig.json

We add typescript

pnpm --filter common add -D typescript

We declare exports and commands

packages/common/package.json

{
  "name": "common",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "files": [
    "dist"
  ],
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./constants": {
      "import": "./dist/constants/index.js",
      "types": "./dist/constants/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch --preserveWatchOutput"
  },
  "devDependencies": {
    "@biomejs/biome": "2.2.4"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.15.0"
}

tsconfig to build package

packages/common/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Create src/ and constants/ directories

// src/index.ts
export * from "./constants/index.js";

// src/constants/index.ts
export * from "./role.js";

// src/constants/role.ts
export const ROLE = {
  ADMIN: "admin",
  USER: "user",
  GUEST: "guest",
} as const;

export type Role = keyof typeof ROLE;

Backend configuration

The backend imports common and exposes an RPC type for the frontend.

apps/backend/package.json

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json"
  },
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    },
    "./hc": {
      "import": {
        "types": "./dist/hc.d.ts",
        "default": "./dist/hc.js"
      }
    }
  },
  "dependencies": {
    "common": "workspace:^",
  }
}

Add roles to our user.service. you can install depencies with pnpm install inside apps/backend if you want to see imports working

apps/backend/package.json

import { ROLE } from "common/constants";

const users = [
  { id: "1", name: "John Doe", age: 30, role: ROLE.ADMIN },
  { id: "2", name: "Jane Smith", age: 25, role: ROLE.USER },
];

// ...

export const createUser = (name: string, age: number) => {
  const newUser = {
    id: (users.length + 1).toString(),
    name,
    age,
    role: ROLE.USER,
  };

  const logger = getLoggerStore();

  logger.info({ user: newUser }, "Creating new user from service");

  users.push(newUser);
  return newUser;
};

Define hc.ts to export the RPC type:

apps/backend/src/hc.ts

import type app from "./app.js";

export type AppType = typeof app;

Ensure routes are chained on a single Hono app before exporting default” sentence. Typing for hc<AppType> depends on that.

Ensure backend emits types:

apps/backend/tsconfig.build.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Build backend once to generate type definitions:

pnpm build

Create an RPC client

├── index.html
├── package.json
├── public
│   └── vite.svg
├── README.md
├── src
│   ├── api
│   │   ├── fetchUsers.ts
│   │   └── hc.ts
│   ├── App.css
│   ├── App.tsx
│   ├── assets
│   │   └── react.svg
│   ├── hooks
│   │   └── useUsers.ts
│   ├── index.css
│   └── main.tsx
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

add workspace dependencies

apps/frontend/package.json

{
  "dependencies": {
    "backend": "workspace:^",
    "common": "workspace:^"
  }
}

We use Vite’s dev server proxy to reach the API at /api.

apps/frontend/vite.config.ts

export default defineConfig({
  // ...
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

apps/frontend/src/api/hc.ts

import type { AppType } from "backend/hc";
import { hc } from "hono/client";

export const client = hc<AppType>("/api");

Fetch users with RPC:

apps/frontend/src/api/fetchUsers.ts

import { client } from "./hc.ts";

export async function fetchUsers() {
  const res = await client.users.$get();
  const data = await res.json();
  return data;
}

Wrap in a TanStack Query hook:

apps/frontend/src/hooks/useUsers.ts

import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/fetchUsers";

export const useUsers = () =>
  useQuery({
    queryKey: ["users"],
    queryFn: () => fetchUsers(),
  });

Configure QueryClient in entrypoint:

apps/frontend/src/main.tsx

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>
);

Display users in React:

apps/frontend/src/App.tsx

import { useUsers } from './hooks/useUsers';
import './App.css';
import { ROLE } from 'common/constants';

function App() {
  const { data, isLoading, isError } = useUsers();

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

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>
            {user.name} ({user.age}) - {user.role}
          </li>
        ))}
      </ul>
      <p>Available roles: {Object.values(ROLE).join(', ')}</p>
    </div>
  );
}

export default App;

Convenience scripts

Add workspace scripts to run everything easily:

package.json

{
  "scripts": {
    "build:backend": "pnpm --filter backend build",
    "build:frontend": "pnpm --filter frontend build",
    "build:common": "pnpm --filter common build",
    "dev": "pnpm dev:frontend & pnpm dev:backend & pnpm dev:common",
    "dev:backend": "pnpm --filter backend dev",
    "dev:frontend": "pnpm --filter frontend dev",
    "dev:common": "pnpm --filter common dev"
  }
}

you can now open http://localhost:5173 to test the frontend connected to the backend.