Type-Safe API Calls with Hono RPC and TanStack Query — Agentuity Documentation

Type-Safe API Calls with Hono RPC and TanStack Query

Get end-to-end type safety between your Agentuity API routes and React frontend using Hono RPC and TanStack Query

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-validator

Server: Define Typed Routes

Use method chaining on new Hono<Env>() so TypeScript can infer the full route tree:

typescriptsrc/api/users.ts
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;

Export Route Types to the Client

Use a dedicated type-only file for frontend imports:

typescriptsrc/shared/api-types.ts
// 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:

typescriptsrc/web/api.ts
import { hc } from 'hono/client';
import type { UsersRoute } from '../shared/api-types';
 
export const client = hc<UsersRoute>('/api');

This works because:

  1. src/shared/api-types.ts uses only export type { ... }, so TypeScript erases it at compile time.
  2. verbatimModuleSyntax: true keeps import type and export type honest instead of rewriting them into value imports.
  3. Your server route file stays out of the browser bundle.

Client: Use with TanStack Query

Setup the Query Provider

tsxsrc/web/frontend.tsx
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

typescriptsrc/web/hooks/useUsers.ts
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

tsxsrc/web/components/UserList.tsx
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:

typescriptsrc/api/posts.ts
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;
typescriptsrc/shared/api-types.ts
export type { UsersRoute } from '../api/users';
export type { PostsRoute } from '../api/posts'; 
typescriptsrc/web/api.ts
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.

typescriptsrc/api/index.ts
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.

typescriptapp.ts
import { createApp } from '@agentuity/runtime'; 
import router from './src/api/index'; 
 
const app = await createApp({ router }); 
 
export default app;
typescriptsrc/shared/api-types.ts
export type { AppRoute } from '../api/index';
typescriptsrc/web/api.ts
import { hc } from 'hono/client';
import type { AppRoute } from '../shared/api-types';
 
export const client = hc<AppRoute>('/api');

For custom mount paths:

typescriptapp.ts
import { createApp } from '@agentuity/runtime';
import myRouter from './src/api/index';
 
const app = await createApp({
   router: { path: '/v1', router: myRouter }, 
});
 
export default app;
typescriptsrc/web/api.ts
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 error

Tips

  • 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.ok before calling res.json() in query functions
  • Use hierarchical query keys: keys like ['users'] and ['users', id] make invalidation precise