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.