es reference each other via symlinks during development, enabling instant type resolution without publishing.
# pnpm-workspace.yaml
packages:
- 'packages/server'
- 'packages/client'
Initialize the root workspace and install dependencies:
mkdir fullstack-hono-rpc && cd fullstack-hono-rpc
pnpm init
pnpm install
Step 2: Server Definition & Type Export
The backend package must expose the Hono application type. This is the critical bridge that allows the frontend to infer route signatures. We structure the server using a modular router pattern for scalability.
// packages/server/src/router.ts
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
// Define a typed route group
const v1 = new Hono()
.get('/status', (c) => {
return c.json({ uptime: process.uptime(), env: process.env.NODE_ENV || 'development' })
})
.post('/data', async (c) => {
const payload = await c.req.json<{ label: string; value: number }>()
return c.json({ received: payload, timestamp: Date.now() })
})
app.route('/api/v1', v1)
// Export the inferred type for client consumption
export type ServerRouter = typeof app
// Production-ready server bootstrap
const port = Number(process.env.PORT) || 3000
serve({ fetch: app.fetch, port }, (info) => {
console.log(`Server listening on http://localhost:${info.port}`)
})
Architecture Rationale:
- We export
typeof app rather than manually defining interfaces. This guarantees that the client types always match the actual route handlers.
- Route grouping (
app.route()) keeps the main application clean and enables future middleware scoping.
- Using
c.json() and c.req.json<T>() allows TypeScript to infer request/response shapes automatically.
Step 3: Client Package Configuration
The frontend package consumes the server types via a workspace dependency. This avoids network requests for type resolution and keeps the development loop tight.
// packages/client/package.json
{
"name": "@workspace/client",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --build && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@workspace/server": "workspace:*",
"hono": "^4.6.19",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"@vitejs/plugin-react": "^4.3.4",
"@types/react": "^18.3.18"
}
}
Architecture Rationale:
@workspace/server is placed in devDependencies because we only need its TypeScript types during compilation. The runtime server code is never bundled into the client.
hono is installed in the client to access the hc factory and RPC utilities.
Step 4: Development Proxy Configuration
During local development, the Vite dev server must forward API requests to the Hono backend. This avoids CORS issues and mimics production routing.
// packages/client/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
}
}
}
})
Architecture Rationale:
changeOrigin: true ensures the Host header matches the target server, preventing routing failures in certain environments.
- The
/api prefix matches the server's route group, creating a seamless path mapping.
Step 5: Type-Safe Client Implementation
The frontend consumes the RPC client using the exported ServerRouter type. The hc factory generates a fully typed interface based on the server definition.
// packages/client/src/App.tsx
import { useState, useEffect } from 'react'
import { hc } from 'hono/client'
import type { ServerRouter } from '@workspace/server'
// Initialize client with base path matching Vite proxy
const apiClient = hc<ServerRouter>('/api')
function App() {
const [status, setStatus] = useState<{ uptime: number; env: string } | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await apiClient.v1.status.$get()
if (res.ok) {
const data = await res.json()
setStatus(data)
}
} catch (error) {
console.error('Failed to fetch status:', error)
} finally {
setLoading(false)
}
}
fetchStatus()
}, [])
if (loading) return <p>Initializing connection...</p>
if (!status) return <p>Connection failed.</p>
return (
<main>
<h1>System Status</h1>
<p>Environment: {status.env}</p>
<p>Uptime: {status.uptime.toFixed(2)}s</p>
</main>
)
}
export default App
Architecture Rationale:
hc<ServerRouter>('/api') aligns with the Vite proxy configuration. The compiler automatically strips this prefix when matching routes.
apiClient.v1.status.$get() is fully typed. If the server route changes, TypeScript will flag the mismatch immediately.
- Response handling uses
res.ok and res.json() to maintain type safety while respecting HTTP semantics.
Pitfall Guide
1. Forgetting to Export the Server Type
Explanation: The RPC client relies on typeof app to infer routes. If the server package only exports runtime functions or middleware, the client receives any types.
Fix: Always export export type ServerRouter = typeof app from the server entry point. Ensure the TypeScript compiler can resolve it via tsconfig.json paths or workspace references.
2. Workspace Dependency Not Linked
Explanation: Adding @workspace/server to package.json without running pnpm install leaves the symlink unresolved. TypeScript will report module resolution errors.
Fix: Run pnpm install at the workspace root after modifying any package.json. Verify symlinks exist in node_modules/@workspace/.
3. Vite Proxy Path Mismatch
Explanation: If the Vite proxy target or path prefix doesn't match the server's route structure, requests will 404 or bypass the backend entirely.
Fix: Align the proxy key ('/api') with the server's base route. Use changeOrigin: true to prevent host header mismatches. Test with curl http://localhost:5173/api/v1/status during development.
4. Client Base URL Divergence
Explanation: Initializing hc<ServerRouter>('/') when the server expects /api causes route resolution failures. The client will attempt to call /v1/status instead of /api/v1/status.
Fix: Pass the exact base path to hc that matches your proxy or production routing. Document this convention in your workspace README.
5. Ignoring Response Type Narrowing
Explanation: Assuming res.json() always succeeds without checking res.ok can lead to runtime crashes when the server returns error status codes.
Fix: Always guard with if (res.ok) before parsing. Use union types or error boundaries to handle non-2xx responses gracefully.
6. Circular Workspace Dependencies
Explanation: Importing runtime server code (e.g., database clients, middleware implementations) into the client package creates circular dependencies and bloated bundles.
Fix: Restrict client imports to type-only declarations (import type { ServerRouter }). Use isolatedModules: true in tsconfig.json to enforce type-only imports.
7. Production Build Path Issues
Explanation: Hono RPC works seamlessly in development, but production deployments often require explicit static asset serving or reverse proxy configuration.
Fix: Configure your deployment platform to serve the Vite build output and proxy /api requests to the Hono server. Never bundle the server into the client.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, rapid iteration | Hono RPC + pnpm workspace | Zero codegen, instant type sync, minimal CI overhead | Low (reduced tooling maintenance) |
| Multi-language frontend (Swift, Kotlin) | OpenAPI + codegen | Language-agnostic contract, standardized client generation | Medium (codegen pipeline maintenance) |
| Public API with third-party consumers | OpenAPI + Swagger UI | Developer portal compatibility, versioned contract publishing | High (documentation & versioning overhead) |
| Internal microservices with shared TS stack | Hono RPC or tRPC | Type safety across services, reduced serialization overhead | Low (shared type definitions) |
Configuration Template
# pnpm-workspace.yaml
packages:
- 'packages/*'
// packages/server/package.json
{
"name": "@workspace/server",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --build",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"hono": "^4.6.19"
},
"devDependencies": {
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
// packages/client/package.json
{
"name": "@workspace/client",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --build && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@workspace/server": "workspace:*",
"hono": "^4.6.19",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"@vitejs/plugin-react": "^4.3.4",
"@types/react": "^18.3.18"
}
}
// packages/client/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
}
}
}
})
Quick Start Guide
- Initialize Workspace: Run
mkdir fullstack-hono-rpc && cd fullstack-hono-rpc, then pnpm init and create pnpm-workspace.yaml with packages: ['packages/*'].
- Create Packages: Execute
mkdir -p packages/server packages/client. Copy the provided package.json files into each directory.
- Install Dependencies: Run
pnpm install at the workspace root. Verify symlinks in node_modules/@workspace/.
- Add Source Files: Create
packages/server/src/index.ts with the Hono router and type export. Create packages/client/src/App.tsx with the hc client implementation.
- Launch Development: Add
"dev": "pnpm run --parallel dev" to the root package.json. Run pnpm dev and navigate to http://localhost:5173 to verify type-safe API communication.