Published on

Hono RPC — End-to-End Type Safety Without tRPC or GraphQL

Authors

Introduction

tRPC revolutionized full-stack type safety, but it requires shared TypeScript packages and adds framework overhead. Hono RPC takes a simpler approach: share type definitions through auto-inference, not code generation. This guide covers Hono RPC architecture, monorepo setup, and why it''s superior to tRPC for REST APIs.

Understanding Hono RPC

Hono RPC is deceptively simple. Your server routes become types automatically:

// Server (Hono)
const app = new Hono()

app.get('/users', (c) => {
  return c.json([{ id: 1, name: 'Alice' }])
})

app.post('/users', (c) => {
  const body = c.req.json()
  return c.json({ id: 2, name: body.name }, 201)
})

export type App = typeof app

The client imports App and gets full type inference:

// Client
import { hc } from 'hono/client'
import type { App } from '../server'

const client = hc('http://localhost:3000')

// ✅ Fully typed — TypeScript knows return type is User[]
const users = await client.users.$get()

// ✅ Fully typed — TypeScript knows method expects POST
const response = await client.users.$post({ json: { name: 'Bob' } })

No code generation. No separate RPC layer. Just standard REST with type inference.

Defining Typed Routes with Hono

Combine Hono with Zod for complete type safety:

import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
})

type User = z.infer<typeof UserSchema>

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})

const app = new Hono()

app.get('/users', (c) => {
  const users: User[] = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
  ]
  return c.json(users)
})

app.post(
  '/users',
  zValidator('json', CreateUserSchema),
  (c) => {
    const { name, email } = c.req.valid('json')
    const user: User = { id: 2, name, email }
    return c.json(user, 201)
  }
)

export type App = typeof app

The App type includes validation and response types. The client inherits all of it.

The hc Client Factory

hc generates a type-safe client from your App type:

import { hc } from 'hono/client'
import type { App } from '../server'

const client = hc('http://localhost:3000')

// GET /users — returns Promise<User[]>
const users = await client.users.$get()

// POST /users — expects { name: string; email: string }
const newUser = await client.users.$post({
  json: { name: 'Charlie', email: 'charlie@example.com' },
})

// GET /users/:id
const user = await client.users[':id'].$get({
  param: { id: '1' },
})

Route parameters are in param, body in json, query in query. All typed.

Streaming RPC Responses

Stream responses for long-running operations:

app.get('/stream', async (c) => {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(`data: ${JSON.stringify({ index: i })}\n\n`)
        await new Promise(resolve => setTimeout(resolve, 100))
      }
      controller.close()
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream' },
  })
})

The client consumes streams with proper types:

const response = await client.stream.$get()
const reader = response.body?.getReader()

while (true) {
  const { done, value } = await reader!.read()
  if (done) break

  const text = new TextDecoder().decode(value)
  // Parse and handle streamed data
}

Authentication with RPC Routes

Guards automatically apply to routes:

const authGuard = (c: Context, next: Next) => {
  const auth = c.req.header('Authorization')
  if (!auth?.startsWith('Bearer ')) {
    throw new HTTPException(401, { message: 'Unauthorized' })
  }

  try {
    const payload = verifyJWT(auth.slice(7))
    c.set('user', payload)
    return next()
  } catch {
    throw new HTTPException(401, { message: 'Invalid token' })
  }
}

app.get('/me', authGuard, (c) => {
  const user = c.get('user')
  return c.json(user)
})

The hc client passes headers automatically:

const client = hc('http://localhost:3000', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
})

const profile = await client.me.$get()

Hono + React Query Integration

React Query pairs perfectly with Hono RPC:

import { useQuery } from '@tanstack/react-query'
import { hc } from 'hono/client'
import type { App } from '../server'

const client = hc('http://localhost:3000')

export function Users() {
  const { data: users, isLoading } = useQuery({
    queryKey: ['users'],
    queryFn: () => client.users.$get(),
  })

  if (isLoading) return &lt;div&gt;Loading...&lt;/div&gt;

  return (
    &lt;ul&gt;
      {users?.map(user =&gt; (
        &lt;li key={user.id}&gt;{user.name}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}

React Query handles caching, invalidation, and retries. Hono provides the types.

Monorepo Setup for Shared Types

Structure your monorepo:

.
├── packages/
│   └── shared/
│       └── src/
│           └── types.ts
├── services/
│   ├── api/
│   │   └── src/
│       └── index.ts
│   └── web/
│       └── src/
│           └── app.tsx

Share types via package exports:

// packages/shared/package.json
{
  "name": "@app/shared",
  "exports": {
    "types": "./src/types.ts"
  }
}

// services/api/src/index.ts
import { UserSchema } from '@app/shared'

// services/web/src/app.tsx
import type { App } from '../../../services/api/src/index.ts'
import type { User } from '@app/shared'

Or share directly without a package:

// services/api/src/index.ts
import { UserSchema } from '../../services/shared/types.ts'

// services/web/src/app.tsx
import type { App } from '../../services/api/src/index.ts'

Hono doesn''t care how you share the App type—just that it''s available.

Hono RPC vs tRPC vs GraphQL

Feature comparison:

FeatureHono RPCtRPCGraphQL
Type Safety✅ Full✅ Full⚠️ Partial
REST API✅ Yes❌ No❌ No
Performance✅ Excellent✅ Good❌ Overhead
Learning Curve✅ Minimal⚠️ Medium❌ Steep
Cache Invalidation✅ Standard HTTP⚠️ Complex⚠️ Complex
Client Size✅ <5kb⚠️ 15kb+❌ 50kb+

Use Hono RPC for: REST APIs, CRUD apps, performance-critical systems.

Use tRPC for: Full-stack type safety (but don''t want REST).

Use GraphQL for: Complex query shapes, real-time subscriptions, federation.

Error Handling with Type Safety

Define typed errors:

type ErrorResponse = {
  error: string
  code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'UNAUTHORIZED'
}

app.get('/users/:id', (c) => {
  const id = parseInt(c.req.param('id'))

  if (isNaN(id)) {
    return c.json&lt;ErrorResponse&gt;(
      { error: 'ID must be a number', code: 'VALIDATION_ERROR' },
      400
    )
  }

  const user = findUser(id)
  if (!user) {
    return c.json&lt;ErrorResponse&gt;(
      { error: 'User not found', code: 'NOT_FOUND' },
      404
    )
  }

  return c.json(user)
})

export type App = typeof app

The client knows possible error shapes:

try {
  const user = await client.users[':id'].$get({ param: { id: '1' } })
} catch (err: any) {
  const error = err as ErrorResponse
  if (error.code === 'NOT_FOUND') {
    // Handle not found
  }
}

Middleware for Cross-Cutting Concerns

Middleware applies to all routes:

app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  const duration = Date.now() - start
  c.header('X-Response-Time', `${duration}ms`)
})

app.use('/api/*', authGuard)

app.use('*', async (c, next) => {
  try {
    await next()
  } catch (err) {
    console.error(err)
    return c.json(
      { error: 'Internal server error' },
      500
    )
  }
})

Middleware executes in order. No magic—just standard handler composition.

Checklist

  • Create Hono app with typed routes
  • Add Zod validators to all endpoints
  • Export App type from server
  • Generate hc client in frontend
  • Integrate React Query for data fetching
  • Test type inference end-to-end
  • Add authentication guards
  • Document API with Swagger (auto-generated)
  • Deploy server and client
  • Monitor type safety with TypeScript strict mode

Conclusion

Hono RPC is the simplest path to full-stack type safety. No code generation, no framework overhead, no GraphQL complexity. It''s REST with types. For teams building CRUD apps, microservices, or anything REST-shaped, Hono RPC is the clear choice. tRPC is more powerful for RPC-specific use cases, but Hono RPC''s simplicity and REST compatibility make it the default for most projects.