Skip to content
QueryWeave

Introduction

QueryWeave is a framework-independent, type-safe URL state engine. You define your query parameters once and reuse that definition everywhere the query appears: in the browser, on the server, in a router, or in a test.

A URL query is a string, a navigation event, a link someone can share, and input your program did not write, all at once. Applications often end up describing it more than once:

  • a parser in the client component that reads page as a number,
  • a second parser on the server that reads it again,
  • a serializer somewhere else that writes it back,
  • and a default value repeated in all three.

Those descriptions drift. The server accepts page=0, the client does not. A link built by hand produces ?sort=name&page=1 while the application produces ?page=1&sort=name, and the two cache differently. None of this throws an error, so it tends to go unnoticed.

A query model describes your query parameters once. Each parameter defines how to decode and encode its value, so parsing and serialization cannot drift apart.

import { defineQueryModel, param } from "@queryweave/core";
const products = defineQueryModel({
search: param.text().optional(),
page: param.integer({ min: 1 }).default(1),
sort: param.choice(["name", "created_at", "price"]).default("created_at"),
});

The model is a plain value that knows nothing about browsers, reactivity, or navigation. The same object decodes a request on the server, encodes a link, binds to a component, and runs in a test.

The path a query takes through the model, stage by stage.

  1. Raw query — ?search=vue&page=2
  2. decode()
  3. Typed state — { search: "vue", page: 2 }
  4. encode()
  5. Canonical query — search=vue&page=2

Raw query → decode(). decode() → Typed state. Typed state → encode(). encode() → Canonical query.

The same model decodes a query string into typed values (with issues for invalid input) and encodes typed values back into a canonical query string.
  • A value equal to its declared default is omitted from canonical output. Two states that mean the same thing produce the same URL.
  • An invalid value recovers to its default or to undefined, and reports an issue. ?page=abc decodes to page 1 with an invalid issue on page. Decoding does not throw.
  • Repeated keys survive. ?tag=a&tag=b decodes to both values. The query is not flattened to Record<string, string>.
  • An update encodes once, writes the URL at most once, and notifies subscribers at most once. A transaction() can change several fields in its callback and still commits once, at the end. If the result matches the URL already there, nothing is written.
  • Keys your model does not define stay in the URL. If the current URL is ?page=2&utm_source=newsletter and your model only manages page, updating page writes ?page=3&utm_source=newsletter. You can start managing some parameters without modelling all of them.
  • A debounce. A runtime can throttle its writes, which holds a burst and bounds how far a shared URL lags behind the reader; a quiet-period delay stays a call-site concern. The reasoning is in ADR 0010.
  • A validation library. Bring your own validator through Standard Schema.