HTTP QUERY Method
The HTTP QUERY method (RFC 10008) is like GET — safe, idempotent, and cacheable — but carries a query in the request body with a Content-Type. It's the standard answer to "I need a GET, but my query is too large or too structured for the URL".
H3 supports QUERY as a first-class method via app.query(), a high-level defineQueryHandler factory, and two lower-level helper utilities.
Define a QUERY Handler
defineQueryHandler captures the whole RFC 10008 ceremony: declare the accepted query formats, and it advertises them via Accept-Query on every response (including errors), validates the request Content-Type (400/415/422, plus 405 for non-QUERY methods), and passes the matched media type to the handler as format:
import { defineQueryHandler, readBody } from "h3";
app.query(
"/books",
defineQueryHandler({
formats: ["application/sql", "application/jsonpath"],
handler: async (event, { format }) => {
const query = await readBody(event, { type: "text" });
return runQuery(format, query);
},
}),
);
Formats may use wildcards (application/*, */*) — format is always the concrete request media type. The sections below show the lower-level utilities it builds on, for when you need custom behavior.
Offer a Cacheable GET Equivalent
A QUERY response is not URL-addressable (and content-keyed QUERY caching is not deployed in practice), so browsers and CDNs won't reuse it. RFC 10008 (§2.3) suggests advertising an equivalent, cacheable GET via the Content-Location header. Pass get to defineQueryHandler and register the handler for both methods — the same handler serves the advertised GET, so no server-side result store is needed:
const searchBooks = defineQueryHandler({
formats: ["application/sql", "application/jsonpath"],
get: "q",
handler: (event, { format, query }) => runQuery(format, query),
});
app.get("/books", searchBooks).query("/books", searchBooks);
// QUERY /books -> 200 + Content-Location: /books?q=<query>&format=<format>
// GET /books?q=... -> same result, ordinary HTTP caching applies
With get set, the handler receives the resolved query in its context on both paths (read from the body on QUERY, from the URL param on GET/HEAD). On GET, the format comes from ?format= (customizable via get: { param, formatParam }) and may be omitted when exactly one concrete format is accepted; rejections on the GET path are 400. Content-Location preserves the request's existing search params and is skipped when the equivalent URL would exceed 2048 characters — very long queries are the reason QUERY exists. HEAD requests are served too — h3 automatically matches GET routes for HEAD, so the app.get() registration covers them.
Register a QUERY Handler
Read the request body just like you would for a POST:
import { readBody } from "h3";
app.query("/books", async (event) => {
const query = await readBody(event, { type: "text" });
return runSearch(query);
});
Because QUERY carries an attacker-controllable body, body-size limits apply just like POST.
Advertise Accepted Formats
Use appendAcceptQuery to tell clients which query formats a resource understands. It sets the Accept-Query response header (a Structured Fields List), and can be set on a plain GET too so clients can discover formats before sending a QUERY:
import { appendAcceptQuery } from "h3";
app.get("/books", (event) => {
appendAcceptQuery(event, ["application/sql", "application/jsonpath"]);
// Accept-Query: application/sql, application/jsonpath
return "Send a QUERY request with a SQL or JSONPath body.";
});
Validate the Content-Type
Use requireContentType to enforce the RFC's error semantics. It returns the matched media type, or throws 400 (missing), 415 (unsupported), or 422 (malformed):
import { requireContentType, readBody } from "h3";
app.query("/books", async (event) => {
const type = requireContentType(event, ["application/sql", "application/jsonpath"]);
const query = await readBody(event, { type: "text" });
return runQuery(type, query);
});
Full Example
A self-contained, runnable demo — a /books resource that accepts SQL-ish and JSONPath queries, validates the Content-Type, and advertises a cacheable GET alternative. It also serves a small interactive page at /.
GET, QUERY is not CORS-safelisted, so browsers send a preflight. If you pass an explicit methods allowlist to handleCors, include "QUERY".