@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.
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:
| Package | Purpose |
|---|---|
@kevincii/http-query-core | Framework-agnostic engine: query building, filters, serialization, transport, caching, pagination. Zero dependencies. |
@kevincii/http-query-client | Batteries-included client for browser + Node with a ready-to-use default. |
@kevincii/http-query-react | React hooks (useHttpQuery, infinite queries, mutations) with a shared cache. |
@kevincii/http-query-next | Next.js server client, RSC data fetching, and App Router route-handler helpers. |
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.
| mode | What goes on the wire | When 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
{ 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
| Situation | Recommended 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
| Operator | Meaning |
|---|---|
eq / ne | Equal / not equal |
gt / gte | Greater than / greater or equal |
lt / lte | Less than / less or equal |
in / nin | Value is one of / none of |
contains | Substring match |
startsWith / endsWith | Prefix / suffix match |
like | SQL-style LIKE pattern |
between | Inclusive 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 });
| Option | Default | Description |
|---|---|---|
pageSize | 20 | Items per page. |
pageParam | "page" | Param name for the page number. |
pageSizeParam | "pageSize" | Param name for the page size. |
startPage | 1 | First page number. |
select | auto | Extract items from a response (defaults to array or items/data/results/records). |
getTotal | auto | Extract 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)
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | — | Prepended to every request path. |
headers | Record<string,string> | — | Default headers. |
fallback | HTTPMethod | null | "POST" | Method to fall back to; null disables fallback. |
timeout | number | — | Abort after N milliseconds. |
retries | number | 0 | Retry count for transient failures on safe methods. |
cache | boolean | false | Enable in-memory caching by default. |
cacheTTL | number | 5000 | Cache lifetime in milliseconds. |
mode | "auto"|"query"|"params" | "auto" | Wire strategy for query(). |
serialize | SerializeOptions | {} | Default serialization options. |
fetch | typeof fetch | global | Inject a custom fetch (Node polyfill, tests). |
Per-request options (QueryOptions)
| Option | Type | Description |
|---|---|---|
method | HTTPMethod | Override the method (default "QUERY"). |
headers | Record<string,string> | Merged over client headers. |
signal | AbortSignal | External abort signal, composed with the timeout. |
timeout / retries | number | Per-request overrides. |
fallback | HTTPMethod | null | Per-request fallback override. |
responseType | "json"|"text"|"blob"|"arrayBuffer" | How to parse the body (default "json"). |
cache / cacheTTL | boolean / number | Per-request cache control. |
mode | QueryMode | Per-request wire strategy. |
serialize | SerializeOptions | Per-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 Core — createClient, 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?)
| Option | Default | Description |
|---|---|---|
enabled | true | Skip fetching until true. |
staleTime | 0 | Serve cached data without refetching for N ms. |
key | auto | Override the auto-generated cache key. |
request | — | Options forwarded to client.query(). |
onSuccess / onError | — | Lifecycle 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 / option | Description |
|---|---|
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";