@kevincii/http-query

A type-safe, QUERY-first toolkit for complex API reads. Pass an object — get a request. No hand-built query strings, no ad-hoc filter encoding.

Type-safe queries Nested filters Automatic URL / body Caching Pagination Retries & timeout Browser + Node

Overview

The real value of this ecosystem is a cleaner way to deal with complex API queries. Instead of assembling strings everywhere:

fetch(`/users?page=1&sort=name&active=true`)

you write a typed object and let the library build the request:

client.query("/users", {
  page: 1,
  sort: "name",
  active: true,
});

And for complex reads with nested filters, the library decides whether to send an HTTP QUERY request with a JSON body or fall back to query parameters:

client.query("/users", {
  filter: {
    age: { gte: 18 },
    country: "DE",
  },
});

The ecosystem has four packages, each building on the one before it:

PackagePurpose
@kevincii/http-query-coreFramework-agnostic engine: query building, filters, serialization, transport, caching, pagination. Zero dependencies.
@kevincii/http-query-clientBatteries-included client for browser + Node with a ready-to-use default.
@kevincii/http-query-reactReact hooks (useHttpQuery, infinite queries, mutations) with a shared cache.
@kevincii/http-query-nextNext.js server client, RSC data fetching, and App Router route-handler helpers.
About the QUERY method. QUERY is a safe, body-bearing read method. As of 2026 most browsers cannot send it via fetch, so in the browser the client automatically falls back to POST or GET. In Node (≥20) QUERY is sent natively.

Installation

Install the package(s) for your environment:

# plain browser / Node app
npm install @kevincii/http-query-client

# React app
npm install @kevincii/http-query-react @kevincii/http-query-core react

# Next.js app
npm install @kevincii/http-query-next @kevincii/http-query-core @kevincii/http-query-react

The client, react, and next packages all re-export the core API, so you rarely install core on its own.

Core — getting started

Create a client and issue a query. createClient returns an HttpQueryClient.

import { createClient } from "@kevincii/http-query-core";

const client = createClient({ baseUrl: "https://api.example.com" });

interface User { id: number; name: string }

const users = await client.query<User[]>("/users", {
  page: 1,
  sort: "name",
  filter: { age: { gte: 18 }, country: { in: ["DE", "AT"] } },
});

The generic argument types the response. The second argument is a plain params object — it becomes a JSON body for the QUERY request, and is serialized to a query string automatically if the request falls back to GET.

query() vs request()

query() is the ergonomic entry point for reads. request() is the low-level method for full control (any HTTP method, explicit body).

// High-level read — params object, QUERY-first
await client.query<User[]>("/users", { active: true });

// Low-level — explicit method and body (e.g. writes)
await client.request<User>("/users", { method: "POST", body: { name: "Ada" } });

Query modes — auto vs params

The mode option controls how query() puts parameters on the wire. Set it once when creating the client, or override it per request as a third argument.

modeWhat goes on the wireWhen to use
"auto" (default) QUERY with JSON body → falls back to POST (JSON body) → GET (query string) on 405/501 Most apps. Sends the richest possible request, degrades automatically.
"query" Same as auto today — always attempts QUERY first When you control client and server and know QUERY is supported.
"params" Plain GET with all params serialized into the query string — no body Servers that only speak GET, CDN-cacheable reads, or simple flat params.

Mode "auto" — QUERY-first with automatic fallback

This is the default. The client sends an HTTP QUERY request with a JSON body. If the server responds with 405 Method Not Allowed or 501 Not Implemented, the client automatically retries with the configured fallback method ("POST" by default).

import { createClient } from "@kevincii/http-query-core";

const client = createClient({ baseUrl: "https://api.example.com" });
// ↑ mode: "auto" and fallback: "POST" are the defaults

// What the client sends on the wire:
// 1st attempt → QUERY /users  body: { "filter": { "age": { "gte": 18 } }, "sort": "name" }
// Server returns 405 → retry automatically
// 2nd attempt → POST  /users  body: { "filter": { "age": { "gte": 18 } }, "sort": "name" }
const users = await client.query("/users", {
  filter: { age: { gte: 18 } },
  sort: "name",
});

// Override the fallback to GET (params serialized into the query string)
const client2 = createClient({ baseUrl: "/api", fallback: "GET" });
// QUERY /users {...} → 405 → GET /users?filter[age][gte]=18&sort=name
Per-request override. Pass { mode: "auto" } as the third argument to override the client-level mode for a single call without creating a new client.
// Client defaults to "params", but this one call uses auto
await client.query("/search", { q: "Ada" }, { mode: "auto" });

Mode "params" — always GET with a query string

Every call becomes a plain GET request. Parameters are serialized into the URL using bracket notation for nested objects and arrays. No request body is sent. Use this when the server only handles GET, when you need CDN-level caching, or when params are shallow enough to fit in a URL.

import { createClient } from "@kevincii/http-query-core";

const client = createClient({ baseUrl: "https://api.example.com", mode: "params" });

// GET /users?page=1&sort=name&filter[age][gte]=18&filter[country][in]=DE&filter[country][in]=AT
await client.query("/users", {
  page: 1,
  sort: "name",
  filter: {
    age: { gte: 18 },
    country: { in: ["DE", "AT"] },
  },
});

// Tags use repeat format by default: GET /tags?tag=a&tag=b
await client.query("/tags", { tag: ["a", "b"] });

// Switch to comma format for this request
// GET /tags?tag=a,b
await client.query("/tags", { tag: ["a", "b"] }, {
  mode: "params",
  serialize: { arrayFormat: "comma" },
});

Choosing between the modes

SituationRecommended mode
General-purpose app, server may or may not support QUERY"auto"
You control both client and server, QUERY is enabled"query"
Server only accepts GET, or you need CDN caching"params"
Params are deeply nested — bracket notation gets long"auto" (JSON body stays clean)
Params are shallow and you want bookmarkable URLs"params"

Filters

Filter operators are plain, serializable objects. Drop them under any key (commonly filter) and they map to bracket-notation params or a nested JSON body with no manual work.

import type { Filter } from "@kevincii/http-query-core";

const params = {
  filter: {
    age: { gte: 18, lt: 65 },
    country: { in: ["DE", "AT"] },
    name: { contains: "an" },
  } satisfies Filter,
};

await client.query("/users", params);

Available operators

OperatorMeaning
eq / neEqual / not equal
gt / gteGreater than / greater or equal
lt / lteLess than / less or equal
in / ninValue is one of / none of
containsSubstring match
startsWith / endsWithPrefix / suffix match
likeSQL-style LIKE pattern
betweenInclusive range [min, max]

Serialization

serializeParams turns a (possibly deeply nested) object into a query string. It is used automatically on the GET fallback, and is exported for direct use.

import { serializeParams } from "@kevincii/http-query-core";

serializeParams({ page: 1, filter: { age: { gte: 18 } }, tag: ["a", "b"] });
// "page=1&filter[age][gte]=18&tag=a&tag=b"

Array formats (serialize.arrayFormat)

Format{ tag: ["a","b"] }
"repeat" (default)tag=a&tag=b
"bracket"tag[]=a&tag[]=b
"comma"tag=a,b
"index"tag[0]=a&tag[1]=b
createClient({ serialize: { arrayFormat: "bracket" } });
// or per request:
client.query("/x", { tag: ["a", "b"] }, { serialize: { arrayFormat: "comma" } });

Other serialize options: encodeValues (default true) and skipNulls (default true).

Pagination

Three helpers cover offset-based pagination. They accept any object with a query() method.

import { queryPage, paginate, collectPages } from "@kevincii/http-query-core";

// One normalized page
const page = await queryPage(client, "/users", { active: true }, 1, { pageSize: 20 });
// { items, page, pageSize, total?, hasNext, nextParams }

// Lazily iterate every page
for await (const batch of paginate(client, "/users", { active: true })) {
  render(batch);
}

// Eagerly collect everything
const all = await collectPages(client, "/users", { active: true }, { pageSize: 50 });
OptionDefaultDescription
pageSize20Items per page.
pageParam"page"Param name for the page number.
pageSizeParam"pageSize"Param name for the page size.
startPage1First page number.
selectautoExtract items from a response (defaults to array or items/data/results/records).
getTotalautoExtract the total count (defaults to total/totalCount/count).

Errors & middleware

Typed error classes let you branch on failure type:

import { HttpError, TimeoutError, NetworkError, ParseError } from "@kevincii/http-query-core";

try {
  await client.query("/users", { page: 1 });
} catch (err) {
  if (err instanceof HttpError) console.error(err.status, err.body);
  else if (err instanceof TimeoutError) console.error("timed out");
}

Register hooks via the middleware stack:

client.middleware.useBefore((init) => {
  init.headers = { ...init.headers, Authorization: `Bearer ${token}` };
  return init;
});
client.middleware.useAfter((res) => res);
client.middleware.useOnError((err) => report(err));

Options reference

createClient(options)

OptionTypeDefaultDescription
baseUrlstringPrepended to every request path.
headersRecord<string,string>Default headers.
fallbackHTTPMethod | null"POST"Method to fall back to; null disables fallback.
timeoutnumberAbort after N milliseconds.
retriesnumber0Retry count for transient failures on safe methods.
cachebooleanfalseEnable in-memory caching by default.
cacheTTLnumber5000Cache lifetime in milliseconds.
mode"auto"|"query"|"params""auto"Wire strategy for query().
serializeSerializeOptions{}Default serialization options.
fetchtypeof fetchglobalInject a custom fetch (Node polyfill, tests).

Per-request options (QueryOptions)

OptionTypeDescription
methodHTTPMethodOverride the method (default "QUERY").
headersRecord<string,string>Merged over client headers.
signalAbortSignalExternal abort signal, composed with the timeout.
timeout / retriesnumberPer-request overrides.
fallbackHTTPMethod | nullPer-request fallback override.
responseType"json"|"text"|"blob"|"arrayBuffer"How to parse the body (default "json").
cache / cacheTTLboolean / numberPer-request cache control.
modeQueryModePer-request wire strategy.
serializeSerializeOptionsPer-request serialization options.

Cancel a request with an AbortController: pass { signal: controller.signal } and call controller.abort().

Client — @kevincii/http-query-client

The batteries-included package for browser and Node apps. It re-exports the entire core API and adds browser-friendly defaults (QUERY with automatic POST fallback).

import { client, createBrowserClient, query } from "@kevincii/http-query-client";

// 1) The shared default client — zero config
const users = await client.query("/users", { active: true });

// 2) A configured browser client (POST fallback preset)
const api = createBrowserClient({ baseUrl: "https://api.example.com" });
await api.query("/users", { page: 1 });

// 3) The top-level shortcut backed by a default client
const data = await query("/users", { sort: "name" });

Everything documented under CorecreateClient, filters, pagination, options — is available from this package too.

React — @kevincii/http-query-react

Wrap your app in a provider, then use hooks. The provider holds the client and a shared, deduped query cache.

import { HttpQueryProvider, useHttpQuery } from "@kevincii/http-query-react";

function App() {
  return (
    <HttpQueryProvider clientOptions={{ baseUrl: "/api" }}>
      <Users />
    </HttpQueryProvider>
  );
}

function Users() {
  const { data, isLoading, error, refetch } = useHttpQuery<User[]>("/users", {
    filter: { active: true, age: { gte: 18 } },
    sort: "name",
  });

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Failed to load.</p>;
  return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

useInfiniteHttpQuery

const { items, fetchNextPage, hasNextPage, isFetchingNextPage } =
  useInfiniteHttpQuery<User>("/users", { active: true }, { pageSize: 20 });

return (
  <>
    {items.map((u) => <Row key={u.id} user={u} />)}
    {hasNextPage && <button onClick={fetchNextPage} disabled={isFetchingNextPage}>More</button>}
  </>
);

useHttpMutation

const { mutateAsync, isLoading } = useHttpMutation(
  (client, vars: NewUser) => client.request("/users", { method: "POST", body: vars }),
  { onSuccess: () => cache.invalidate((k) => k.startsWith("/users")) },
);

await mutateAsync({ name: "Ada" });

Hook options

useHttpQuery(path, params?, options?)

OptionDefaultDescription
enabledtrueSkip fetching until true.
staleTime0Serve cached data without refetching for N ms.
keyautoOverride the auto-generated cache key.
requestOptions forwarded to client.query().
onSuccess / onErrorLifecycle callbacks.

Returns: data, error, isLoading, isFetching, isSuccess, isError, refetch().

useInfiniteHttpQuery options

Accepts enabled, pageSize, pageParam, pageSizeParam, startPage, select, and request. Returns items, pages, hasNextPage, fetchNextPage(), isFetchingNextPage, isLoading, error, reset().

Manual cache control: const cache = useQueryCache() then cache.invalidate(predicate) or cache.setData(key, data).

Next — @kevincii/http-query-next

Server Components (RSC)

Node sends QUERY natively, so the server client uses no fallback by default.

// app/users/page.tsx
import { queryOnServer, configureServerClient } from "@kevincii/http-query-next";

configureServerClient({ baseUrl: process.env.API_URL });

export default async function Page() {
  const users = await queryOnServer<User[]>("/users", { active: true });
  return <UserList users={users} />;
}

Route Handlers

createQueryRouteHandler builds an App Router handler that accepts QUERY (and the POST/GET fallbacks), normalizes params from the JSON body or query string, and returns the resolver result as JSON.

// app/api/users/route.ts
import { createQueryRouteHandler } from "@kevincii/http-query-next";
import { db } from "@/lib/db";

const route = createQueryRouteHandler(async (params) => db.users.search(params));
export const { QUERY, POST, GET } = route;
Export / optionDescription
createServerClient(opts)Node client (native QUERY, no fallback).
configureServerClient(opts)Set the shared server client once (e.g. baseUrl).
queryOnServer(path, params?, opts?)Fetch in a Server Component via the shared client.
createQueryRouteHandler(resolver, opts?)Returns { handler, QUERY, POST, GET }.
parseQuery(search)Reverse serialization: query string → nested object.

Client Components

All React hooks are re-exported here for use in "use client" components.

"use client";
import { HttpQueryProvider, useHttpQuery } from "@kevincii/http-query-next";