npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hono-tanstack-query

v0.0.3

Published

TanStack Query bindings for Hono typed clients — fully typed, zero boilerplate

Readme

hono-tanstack-query

TanStack Query bindings for Hono typed clients — fully typed, zero boilerplate.

npm version npm downloads license TypeScript

Stop writing query keys. Stop duplicating types. hono-tanstack-query wraps your Hono typed client and gives you useQuery, useMutation, and cache helpers — all derived directly from your server's route definitions. No codegen, no schemas, no manual type extraction.


Features

  • 🔒 End-to-end type safety — server route types flow directly into every hook, helper, and cache operation
  • TanStack Query v5 — built for the latest API including useInfiniteQuery and Suspense variants
  • 🧠 Zero query keys — keys are inferred automatically from your route path structure
  • 🔁 Full cache APIgetCache, setCache, invalidate, resetCache, removeCache, prefetch, ensureData
  • 🎯 Optimistic updates — first-class onMutate / onSettled support with typed rollback context
  • 🪶 Zero runtime overhead — all type magic is compile-time only
  • 🛠 Works everywhere — React components, SSR, router loaders, and custom hook composition

Installation

npm install hono-tanstack-query
# or
pnpm add hono-tanstack-query
# or
yarn add hono-tanstack-query

Peer dependencies

| Package | Version | | ----------------------- | --------------------------- | | hono | >= 4 | | @tanstack/react-query | >= 5 | | react | >= 18 (for React hooks) |


Quick Start

1. Export your app type from the server

// server/src/index.ts
import { Hono } from 'hono'

const app = new Hono()
  .get('/posts', (c) => c.json([{ id: '1', title: 'Hello' }], 200))
  .get('/posts/:id', (c) =>
    c.json({ id: '1', title: 'Hello', content: '...' }, 200),
  )
  .post('/posts', (c) => c.json({ id: '2', title: 'New' }, 201))

export type AppType = typeof app

2. Create the typed API client

// client/src/lib/api.ts
import { hc } from 'hono/client'
import { HonoReactQuery } from 'hono-tanstack-query'
import { queryClient } from './query-client'
import type { AppType } from 'your-server'

const honoClient = hc<AppType>('http://localhost:3000')

export const api = HonoReactQuery(honoClient, { queryClient })

3. Use in components — fully typed, no annotations

function PostList() {
  const { data: posts } = api.posts.$get.useQuery()
  //            ^? Post[]  — inferred automatically

  return posts?.map((post) => <div key={post.id}>{post.title}</div>)
}

Configuration

HonoReactQuery accepts a config object as the second argument:

export const api = HonoReactQuery(honoClient, {
  // Required — TanStack QueryClient instance
  queryClient,

  // Default invalidation strategy after every mutation. Default: 'siblings'
  invalidation: 'siblings',

  // Global error handler — fires for every query and mutation error
  onError: (err) => {
    if (err.isUnauthorized()) window.location.href = '/login'
    if (err.isUnprocessable()) toast.error('Validation failed')
  },

  // Global success handler
  onSuccess: (data) => {
    console.log('Request succeeded', data)
  },
})

Queries

Basic query

const { data, isPending, isError, error } = api.posts.$get.useQuery()

Query with path params

const { data: post } = api.posts[':id'].$get.useQuery({
  param: { id: '42' },
})

Query with search params

const { data: orders } = api.orders.$get.useQuery({
  query: { status: 'pending', limit: '10' },
})

Query with TanStack options

All standard TanStack Query options are available alongside your Hono input:

const { data } = api.posts[':id'].$get.useQuery({
  param: { id },
  staleTime: 30_000,
  gcTime: 5 * 60 * 1000,
  enabled: !!id,
  retry: 2,
  refetchOnWindowFocus: false,
})

Query with select transform

const { data: title } = api.posts[':id'].$get.useQuery({
  param: { id },
  select: (post) => post.title,
  //                       ^? post: Post — fully typed
  // data is now: string
})

Suspense query

// Wrap with <Suspense> — no isPending check needed
const { data: post } = api.posts[':id'].$get.useSuspenseQuery({
  param: { id },
})

Mutations

Basic mutation

const { mutate, isPending } = api.posts.$post.useMutation({
  onSuccess: (post) => {
    console.log('Created', post.id)
    navigate(`/posts/${post.id}`)
  },
})

mutate({ json: { title: 'Hello', content: '...' } })

Mutation with per-call invalidation override

const { mutate } = api.posts[':id'].$delete.useMutation({
  invalidate: 'parent', // Override the global strategy for this mutation
  onSuccess: () => navigate('/posts'),
})

Mutation with explicit query keys to invalidate

const { mutate } = api.posts.$post.useMutation({
  invalidate: [['posts'], ['dashboard', 'stats']],
  onSuccess: () => toast.success('Post created'),
})

Optimistic updates

api.posts[':id'].$put.useMutation({
  onMutate: async (variables) => {
    // Cancel in-flight queries to avoid overwriting the optimistic update
    await api.posts[':id'].$get.invalidate({ param: variables.param })

    // Snapshot the current cache value for rollback
    const previous = api.posts[':id'].$get.getCache({ param: variables.param })

    // Optimistically apply the change immediately
    api.posts[':id'].$get.setCache({ param: variables.param }, (old) => ({
      ...old,
      ...variables.json,
    }))

    return { previous }
  },
  onError: (_err, variables, context) => {
    // Roll back to the snapshot on failure
    api.posts[':id'].$get.setCache(
      { param: variables.param },
      () => context?.previous,
    )
  },
  onSettled: (_data, _err, variables) => {
    // Always refetch to sync with the server
    api.posts[':id'].$get.invalidate({ param: variables.param })
  },
})

Infinite / Paginated Queries

const {
  data,
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
} = api.feed.$get.useInfiniteQuery({
  query: { limit: '20' },
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
})

// data.pages is an array of your response type
data?.pages.flatMap((page) => page.items)

Suspense infinite query

const { data } = api.feed.$get.useSuspenseInfiniteQuery({
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

Cache Helpers

All cache helpers are available directly on every endpoint — no queryClient import needed.

// Read the cached value without triggering a fetch
const post = api.posts[':id'].$get.getCache({ param: { id: '42' } })
//    ^? Post | undefined

// Write directly to cache (updater receives the current value)
api.posts[':id'].$get.setCache({ param: { id: '42' } }, (old) => ({
  ...old,
  title: 'Updated title',
}))

// Invalidate — marks stale and triggers a background refetch for active queries
await api.posts.$get.invalidate()

// Invalidate a specific entry
await api.posts[':id'].$get.invalidate({ param: { id: '42' } })

// Remove from cache entirely (no refetch)
api.posts[':id'].$get.removeCache({ param: { id: '42' } })

// Reset — removes from cache AND refetches if the query is actively observed
await api.posts[':id'].$get.resetCache({ param: { id: '42' } })

// Prefetch — runs the query and stores in cache (useful in loaders)
await api.posts.$get.prefetch()

// Ensure data — returns cached data if fresh, otherwise fetches
const posts = await api.posts.$get.ensureData()

Invalidation Strategies

Control what gets invalidated after a mutation via the invalidation config option or per-mutation invalidate option:

| Strategy | What gets invalidated | | ------------ | --------------------------------------------------------------------------------------------------- | | 'siblings' | All queries at the same path level — e.g. after posts.$post, invalidates posts.$get (default) | | 'parent' | One level up — e.g. after posts[':id'].$put, invalidates all posts.* queries | | 'exact' | Only the exact endpoint + input combination | | 'none' | No automatic invalidation |

// Global default
export const api = HonoReactQuery(honoClient, {
  queryClient,
  invalidation: 'parent',
})

// Per-mutation override
api.posts[':id'].$delete.useMutation({
  invalidate: 'parent',
})

// Explicit query key list
api.posts.$post.useMutation({
  invalidate: [['posts'], ['stats', 'post-count']],
})

Error Handling

Every error thrown by a query or mutation is an ApiError instance. Use the typed narrowing methods to handle specific status codes:

import { ApiError } from 'hono-tanstack-query'

const { error } = api.posts[':id'].$get.useQuery({ param: { id } })

if (error instanceof ApiError) {
  error.isNotFound() // 404
  error.isUnauthorized() // 401
  error.isForbidden() // 403
  error.isUnprocessable() // 422
  error.isServerError() // 5xx

  // Typed body — matches your server's declared response shape
  const body = error.body
}

Validation error body

const { mutate } = api.posts.$post.useMutation({
  onError: (err) => {
    if (err.isUnprocessable()) {
      type ValidationError = (typeof api.posts.$post.$infer)['error']['body']
      const body = err.body as ValidationError
      const titleError = body.issues.find((i) => i.path[0] === 'title')?.message
    }
  },
})

Global error handler

export const api = HonoReactQuery(honoClient, {
  queryClient,
  onError: (err) => {
    if (err.isUnauthorized()) window.location.href = '/login'
    toast.error(err.message)
  },
})

Options Builders (SSR / Loaders)

Use queryOptions and infiniteQueryOptions outside of components — for TanStack Router loaders, getServerSideProps, or custom hook composition.

// TanStack Router loader
export const Route = createFileRoute('/posts/$id')({
  loader: ({ params, context }) =>
    context.queryClient.ensureQueryData(
      api.posts[':id'].$get.queryOptions({
        param: { id: params.id },
      }),
    ),
})

// Custom hook composition
function usePostWithFallback(id: string) {
  return useQuery({
    ...api.posts[':id'].$get.queryOptions({ param: { id } }),
    placeholderData: keepPreviousData,
    select: (post) => post.title,
  })
}

// Mutation options builder
const opts = api.posts.$post.mutationOptions({
  onSuccess: () => toast('Post created'),
})
const { mutate } = useMutation(opts)

Type Inference

Use $infer to extract types without any server imports. This is especially useful in monorepos or shared frontend packages:

// Extract response data types
type Post      = typeof api.posts[':id'].$get.$infer['data']
type PostList  = typeof api.posts.$get.$infer['data']

// Extract request input type
type PostInput = typeof api.posts.$post.$infer['input']

// Extract error body type
type PostError = typeof api.posts[':id'].$get.$infer['error']

// Use in component props
interface PostCardProps {
  post: typeof api.posts.$get.$infer['data'][number]
}

$infer is a phantom type namespace — it exists only at compile time and produces zero runtime bytes.


Query Keys

Each endpoint's query key is derived automatically from the route path. You can read the key directly if you need it for manual queryClient operations:

// Get the query key for an endpoint
const key = api.posts[':id'].$get.getQueryKey({ param: { id: '42' } })
// → [['posts', ':id', '$get'], { type: 'query', param: { id: '42' } }]

// Use with queryClient directly
queryClient.invalidateQueries({ queryKey: api.posts.$get.getQueryKey() })

Raw Client Access

Each endpoint exposes the original Hono client method via .call — useful for one-off fetches outside React Query:

const response = await api.posts.$get.call()
const posts = await response.json()

Full Example

// lib/api.ts
import { hc } from 'hono/client'
import { HonoReactQuery } from 'hono-tanstack-query'
import { QueryClient } from '@tanstack/react-query'
import type { AppType } from 'your-server'

export const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
})

const honoClient = hc<AppType>('/api')

export const api = HonoReactQuery(honoClient, {
  queryClient,
  invalidation: 'siblings',
  onError: (err) => {
    if (err.isUnauthorized()) window.location.href = '/login'
  },
})
// components/PostDetail.tsx
import { api } from '@/lib/api'
import { ApiError } from 'hono-tanstack-query'

export function PostDetail({ id }: { id: string }) {
  const {
    data: post,
    isPending,
    error,
  } = api.posts[':id'].$get.useQuery({
    param: { id },
    staleTime: 60_000,
  })
  //  ^? { id: string; title: string; content: string } | undefined

  if (isPending) return <Spinner />
  if (error instanceof ApiError && error.isNotFound()) return <NotFound />

  return <article>{post.title}</article>
}
// components/CreatePost.tsx
import { api } from '@/lib/api'

export function CreatePost() {
  const { mutate, isPending } = api.posts.$post.useMutation({
    onSuccess: (post) => navigate(`/posts/${post.id}`),
  })

  return (
    <button
      onClick={() => mutate({ json: { title: 'Hello', content: '...' } })}
    >
      {isPending ? 'Creating…' : 'Create Post'}
    </button>
  )
}

Contributing

See CONTRIBUTING.md.

License

MIT