Use Hono RPC when your React frontend should call Agentuity API routes with route-inferred request and response types. Add TanStack Query when you want caching, request state, and invalidation around those calls.
Overview
Hono RPC infers client types directly from your route definitions. Combined with TanStack Query, you get:
- Type-safe API calls: request params, body, and response types are inferred from route handlers
- Caching and revalidation: TanStack Query manages server state
- One route contract: the route definition is the source of truth
Installation
bun add @tanstack/react-query zod @hono/zod-validatorAgentuity templates already include hono as an app dependency. If your project does not, add it too because the browser imports hc from hono/client.
Server: Define Typed Routes
Use method chaining on new Hono<Env>() so TypeScript can infer the full route tree:
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const router = new Hono<Env>()
.get('/users', async (c) => {
// In a real app, query your database here
const users = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
];
return c.json({ users });
})
.get('/users/:id', async (c) => {
const id = c.req.param('id');
return c.json({ id, name: 'Alice', email: 'alice@example.com' });
})
.post(
'/users',
zValidator('json', CreateUserSchema),
async (c) => {
const body = c.req.valid('json');
const user = { id: crypto.randomUUID(), ...body };
return c.json({ user }, 201);
}
)
.delete('/users/:id', async (c) => {
const id = c.req.param('id');
return c.json({ deleted: id });
});
// Export the type the client imports
export type UsersRoute = typeof router;
export default router;Chain .get(), .post(), etc. directly on new Hono<Env>(). If you use separate router.get(...) statements, TypeScript can't infer the combined type.
Export Route Types to the Client
Use the type-only barrel shown below for client code. Type-only imports and exports are erased at compile time, while value imports can pull server-only code into the browser bundle.
Use a dedicated type-only file for frontend imports:
// Keep this file type-only
export type { UsersRoute } from '../api/users';
export type { PostsRoute } from '../api/posts';Then import from that file in the frontend:
import { hc } from 'hono/client';
import type { UsersRoute } from '../shared/api-types';
export const client = hc<UsersRoute>('/api');This works because:
src/shared/api-types.tsuses onlyexport type { ... }, so TypeScript erases it at compile time.verbatimModuleSyntax: truekeepsimport typeandexport typehonest instead of rewriting them into value imports.- Your server route file stays out of the browser bundle.
A file like src/api/users.ts may import @agentuity/runtime, database clients, or read process.env at the top level. Keep those files behind a type-only barrel so the frontend never points at the server module directly.
Client: Use with TanStack Query
Setup the Query Provider
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
},
},
});
const root = document.getElementById('root');
if (!root) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);Query Hooks
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '../api';
// Fetch all users
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await client.users.$get();
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
// ^? { users: { id: string; name: string; email: string }[] }
},
});
}
// Fetch a single user by ID
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: async () => {
const res = await client.users[':id'].$get({ param: { id } });
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
// ^? { id: string; name: string; email: string }
},
enabled: !!id,
});
}
// Create a new user
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; email: string }) => {
const res = await client.users.$post({ json: data });
if (!res.ok) throw new Error('Failed to create user');
return res.json();
},
onSuccess: () => {
// Invalidate the users list so it refetches
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// Delete a user
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const res = await client.users[':id'].$delete({ param: { id } });
if (!res.ok) throw new Error('Failed to delete user');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}Use in Components
import { useUsers, useCreateUser, useDeleteUser } from '../hooks/useUsers';
import { useState } from 'react';
export function UserList() {
const { data, isLoading, error } = useUsers();
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
createUser.mutate({ name, email });
setName('');
setEmail('');
}}
>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Adding...' : 'Add User'}
</button>
</form>
<ul>
{data?.users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
<button
onClick={() => deleteUser.mutate(user.id)}
disabled={deleteUser.isPending}
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}Multiple Route Files
When you have multiple route files, export all types from a shared file:
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
const router = new Hono<Env>()
.get('/posts', async (c) => {
return c.json({ posts: [] });
})
.get('/posts/:id', async (c) => {
return c.json({ id: c.req.param('id'), title: '', body: '' });
});
export type PostsRoute = typeof router;
export default router;export type { UsersRoute } from '../api/users';
export type { PostsRoute } from '../api/posts'; import { hc } from 'hono/client';
import type { UsersRoute, PostsRoute } from '../shared/api-types';
export const usersClient = hc<UsersRoute>('/api');
export const postsClient = hc<PostsRoute>('/api'); With Explicit Routing
If you're mounting sub-routers with createApp({ router }), export the composed router type. Mount routers at / when the imported route files already include their path prefixes.
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import users from './users';
import posts from './posts';
const router = new Hono<Env>()
.route('/', users)
.route('/', posts);
export type AppRoute = typeof router;
export default router;If you prefer .route('/users', users), define the users router with relative paths like / and /:id. Otherwise Hono will compose doubled paths such as /users/users.
import { createApp } from '@agentuity/runtime';
import router from './src/api/index';
const app = await createApp({ router });
export default app;export type { AppRoute } from '../api/index';import { hc } from 'hono/client';
import type { AppRoute } from '../shared/api-types';
export const client = hc<AppRoute>('/api');For custom mount paths:
import { createApp } from '@agentuity/runtime';
import myRouter from './src/api/index';
const app = await createApp({
router: { path: '/v1', router: myRouter },
});
export default app;export const client = hc<AppRoute>('/v1'); Why Validation Matters
Hono RPC infers request body types from validator middleware. A c.req.json<T>() annotation only types the local server value, so use zValidator() and c.req.valid('json') when the client should know the request shape.
This is separate from Agentuity agent schemas, where @agentuity/schema is the lightweight default. For Hono middleware, Hono's Zod validator is the direct integration.
The client mutation gets the route-inferred body shape. Content rules such as .email() still run on the server:
createUser.mutate({ name: '', email: 'not-an-email' }); // runtime validation catches this
createUser.mutate({ wrong: 'field' }); // compile-time errorTips
- Chain methods on
new Hono<Env>(): separate statements lose type inference - Use type-only imports: import route types with
import type, or use a type-only shared barrel for stable paths - Match mount paths: the
hc()base URL must match where the router is mounted - Check responses: always check
res.okbefore callingres.json()in query functions - Use hierarchical query keys: keys like
['users']and['users', id]make invalidation precise