HTTP QUERY Method

Accept safe, cacheable requests that carry a query in the body.

The HTTP QUERY method (RFC 10008) is like GETsafe, 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.

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 /.

See the full examples/query.mjs source, or run it locally with node examples/query.mjs.
Unlike GET, QUERY is not CORS-safelisted, so browsers send a preflight. If you pass an explicit methods allowlist to handleCors, include "QUERY".