Type-Safe API Calls with Hono RPC and TanStack Query

Share Hono route types with a React client and wrap calls in TanStack Query

Use Hono RPC when a React client should call a Hono route with inferred request and response types. Add TanStack Query when the UI needs caching, request state, and invalidation around those calls.

npm install hono @tanstack/react-query zod @hono/zod-validator

Define Typed Routes

Chain route methods on the same Hono instance. Hono infers the client contract from that chained route tree.

typescriptsrc/api/users.ts
import { Hono } from 'hono';
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()
  .get('/users', async (c) => {
    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 type UsersRoute = typeof router;
export default router;

Export Route Types

Use a dedicated type-only file for client imports:

typescriptsrc/shared/api-types.ts
export type { UsersRoute } from '../api/users';
export type { PostsRoute } from '../api/posts';

Then create the browser client:

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

The hc() base URL must match where the route is mounted. For a Hono app mounted at /api, use '/api'. For a framework proxy or deployed app with a different base path, use that path instead.

Add TanStack Query

Wrap your React app once:

tsxsrc/client/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';
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');
}
 
createRoot(root).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Create query and mutation hooks around the Hono client:

typescriptsrc/client/use-users.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { usersClient } from './api';
 
export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await usersClient.users.$get();
 
      if (!res.ok) {
        throw new Error(`Failed to fetch users: ${res.status}`);
      }
 
      return res.json();
    },
  });
}
 
export function useUser(id: string) {
  return useQuery({
    queryKey: ['users', id],
    queryFn: async () => {
      const res = await usersClient.users[':id'].$get({ param: { id } });
 
      if (!res.ok) {
        throw new Error(`Failed to fetch user: ${res.status}`);
      }
 
      return res.json();
    },
    enabled: id.length > 0,
  });
}
 
export function useCreateUser() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: async (data: { readonly name: string; readonly email: string }) => {
      const res = await usersClient.users.$post({ json: data });
 
      if (!res.ok) {
        throw new Error(`Failed to create user: ${res.status}`);
      }
 
      return res.json();
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Use the hooks from a component:

tsxsrc/client/UserList.tsx
import { useState } from 'react';
import { useCreateUser, useUsers } from './use-users';
 
export function UserList() {
  const { data, error, isLoading } = useUsers();
  const createUser = useCreateUser();
  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={(event) => {
          event.preventDefault();
          createUser.mutate({ name, email });
          setName('');
          setEmail('');
        }}
      >
        <input value={name} onChange={(event) => setName(event.target.value)} />
        <input value={email} onChange={(event) => setEmail(event.target.value)} />
        <button disabled={createUser.isPending} type="submit">
          {createUser.isPending ? 'Adding...' : 'Add User'}
        </button>
      </form>
 
      <ul>
        {data?.users.map((user) => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
}

Compose Multiple Routers

When you split routes across files, compose them with Hono and export the composed type:

typescriptsrc/api/posts.ts
import { Hono } from 'hono';
 
const router = new Hono()
  .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/api/index.ts
import { Hono } from 'hono';
import posts from './posts';
import users from './users';
 
const app = new Hono()
  .route('/', users)
  .route('/', posts);
 
export type AppRoute = typeof app;
export default app;
typescriptsrc/shared/api-types.ts
export type { AppRoute } from '../api';
typescriptsrc/client/api.ts
import { hc } from 'hono/client';
import type { AppRoute } from '../shared/api-types';
 
export const client = hc<AppRoute>('/api');

If you mount a router at .route('/users', users), define the users router with relative paths like / and /:id. Otherwise Hono composes doubled paths such as /users/users.

With Agentuity Services

Hono RPC works with ordinary Hono route handlers. If the route needs Agentuity service clients, install @agentuity/hono and add the middleware before the route handlers that read c.var.*.

npm install @agentuity/hono
typescriptsrc/api/index.ts
import { Hono } from 'hono';
import { agentuity } from '@agentuity/hono';
import type { Services } from '@agentuity/hono';
 
type Variables = Pick<Services, 'kv'>;
 
const app = new Hono<{ Variables: Variables }>();
 
app.use('*', agentuity());
 
app.get('/sessions/:id', async (c) => {
  const result = await c.var.kv.get('sessions', c.req.param('id'));
  return c.json(result.exists ? result.data : null);
});
 
export type AppRoute = typeof app;
export default app;

Tips

  • chain route methods on the same Hono instance when you want RPC inference
  • import route types with import type or a type-only barrel
  • keep the hc() base URL aligned with your framework proxy or route mount
  • check res.ok before calling res.json() in query functions
  • use validators such as zValidator() when the client should know the request body shape