Back to KB
Difficulty
Intermediate
Read Time
8 min

Hono RPC with React Monorepo Template

By Codcompass Team··8 min read

Zero-Codegen Type Safety: Architecting Full-Stack TypeScript with Hono RPC and pnpm Workspaces

Current Situation Analysis

Maintaining type consistency across frontend and backend boundaries remains one of the most persistent friction points in modern web development. Traditional approaches rely on contract-first methodologies: defining OpenAPI/Swagger schemas, running code generation tools, and importing generated client libraries. While functional, this pipeline introduces significant operational overhead. Every schema change requires a regeneration step, CI/CD integration, and careful version pinning. More critically, it creates a synchronization gap. The generated client can drift from the actual server implementation, leading to runtime type mismatches that TypeScript cannot catch at compile time.

This problem is frequently overlooked because teams treat the API contract as an external dependency rather than an internal implementation detail. Developers assume that type safety requires a dedicated schema language or a heavy RPC framework. The reality is that modern TypeScript compilers are capable of inferring complex route structures, request payloads, and response shapes directly from framework definitions. When the server code itself becomes the single source of truth for types, the entire codegen pipeline becomes redundant.

Hono's built-in RPC mechanism (hc) leverages TypeScript's type inference engine to bridge this gap. By exporting the server application type and passing it to the client factory, the compiler automatically derives route paths, HTTP methods, parameter types, and response shapes. This eliminates contract drift, reduces build complexity, and enables instant refactors across the full stack. The approach is particularly effective in monorepo architectures where workspace dependencies allow seamless type sharing without publishing intermediate packages.

WOW Moment: Key Findings

The architectural shift from codegen-driven contracts to direct type inference produces measurable improvements in developer velocity and system reliability. The following comparison highlights the operational differences between traditional schema-first workflows and Hono RPC within a pnpm workspace topology.

ApproachSetup ComplexitySync LatencyBundle OverheadRefactor Safety
OpenAPI + Orval/tRPCHigh (schema definition, generator config, CI step)Minutes to hours (manual regeneration)+15-40KB (generated client + runtime helpers)Low (types decouple from server implementation)
Hono RPC + WorkspaceLow (export type, import client factory)Zero (compile-time inference)~0KB (uses native fetch + type erasure)High (server changes break client at compile time)

This finding matters because it fundamentally changes how teams handle API evolution. With Hono RPC, renaming a route parameter or changing a response shape triggers immediate TypeScript errors in the frontend codebase. There is no intermediate schema to update, no generator to run, and no version mismatch to debug. The compiler enforces contract integrity automatically, turning what was previously a runtime risk into a compile-time guarantee. This enables smaller teams to maintain full-stack type safety without dedicating engineering resources to contract management tooling.

Core Solution

Building a type-safe full-stack application with Hono RPC and pnpm workspaces requires careful topology design. The goal is to share TypeScript types across packages while keeping runtime dependencies isolated. Below is a production-ready implementation pattern.

Step 1: Workspace Topology Initialization

Start by establishing a pnpm workspace. This ensures that packages 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"
  },
  "devDe

pendencies": { "@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.

```typescript
// 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

  • Initialize pnpm workspace with explicit package directories
  • Export typeof app from the Hono server entry point
  • Add server package as workspace:* devDependency in client
  • Configure Vite proxy with matching path prefix and changeOrigin
  • Initialize hc client with base path aligned to proxy configuration
  • Enforce import type syntax for cross-package type consumption
  • Add parallel dev script to workspace root for simultaneous hot reloading
  • Verify TypeScript compilation succeeds in both packages independently

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
Small team, rapid iterationHono RPC + pnpm workspaceZero codegen, instant type sync, minimal CI overheadLow (reduced tooling maintenance)
Multi-language frontend (Swift, Kotlin)OpenAPI + codegenLanguage-agnostic contract, standardized client generationMedium (codegen pipeline maintenance)
Public API with third-party consumersOpenAPI + Swagger UIDeveloper portal compatibility, versioned contract publishingHigh (documentation & versioning overhead)
Internal microservices with shared TS stackHono RPC or tRPCType safety across services, reduced serialization overheadLow (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

  1. Initialize Workspace: Run mkdir fullstack-hono-rpc && cd fullstack-hono-rpc, then pnpm init and create pnpm-workspace.yaml with packages: ['packages/*'].
  2. Create Packages: Execute mkdir -p packages/server packages/client. Copy the provided package.json files into each directory.
  3. Install Dependencies: Run pnpm install at the workspace root. Verify symlinks in node_modules/@workspace/.
  4. 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.
  5. 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.