The hc RPC client
When both the frontend and the server are written in TypeScript, it helps if the client knows the API addresses and response shapes. Hono provides the hc client from hono/client for this. It takes the server app's type and turns routes into object methods: instead of fetch('/posts') you write client.posts.$get(), and TypeScript checks the parameters and the response type. This approach is called RPC — remote procedure call.
Routes as a chain
The type is built from a chain of calls, so the routes must be written as a single expression. Rewrite the beginning of src/routes/posts.ts:
// src/routes/posts.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { db, type Post } from '../db.js'
import { postSchema } from '../schemas.js'
const posts = new Hono()
.get('/', (c) => {
const rows = db.prepare('SELECT * FROM posts ORDER BY id DESC').all() as Post[]
return c.json(rows)
})
.get('/:id', (c) => {
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(c.req.param('id')) as Post | undefined
return post ? c.json(post, 200) : c.json({ error: 'Post not found' }, 404)
})
.post('/', zValidator('json', postSchema), (c) => {
const { title, body } = c.req.valid('json')
const result = db.prepare('INSERT INTO posts (title, body) VALUES (?, ?)').run(title, body)
return c.json({ id: Number(result.lastInsertRowid) }, 201)
})
export default postsEach method returns the app with the route already added, and the type remembers every route in the chain.
The app type
In src/index.ts keep the result of app.route() and export its type:
// src/index.ts
const routes = app.route('/posts', posts)
export type AppType = typeof routesAppType is only a type: it does not end up in the built JavaScript, and the client does not receive any server code.
The client
The client usually lives in a frontend project that imports the type from the server project. To try it, create a script src/client.ts in the same project:
// src/client.ts
import { hc } from 'hono/client'
import type { AppType } from './index.js'
const client = hc<AppType>('http://localhost:3000/')
const listRes = await client.posts.$get()
const list = await listRes.json()
console.log(list.map((post) => post.title))
const created = await client.posts.$post({
json: { title: 'From the RPC client', body: 'Post text' },
})
console.log(created.status) // 201
const one = await client.posts[':id'].$get({ param: { id: '1' } })
if (one.ok) {
const post = await one.json()
console.log(post.title)
}Start the server, and in a second terminal run npx tsx src/client.ts. The script prints the post titles, the code 201 and the title of the first post. If you mistype an address or forget the body field, TypeScript underlines the error before anything runs.
Method names
| Request | Client call |
|---|---|
GET /posts | client.posts.$get() |
POST /posts | client.posts.$post({ json }) |
GET /posts/:id | client.posts[':id'].$get({ param }) |
GET /search?q= | client.search.$get({ query }) |
Common mistake
Adding routes as separate posts.get(...) statements and then wondering why client.posts has no methods. The type of the posts variable is fixed when it is created and does not change with later calls. For RPC, write routes as a chain and export the type of the chain's result.