Farm.js

Query and Params

Parse search params and route params with typed helpers on the server and synchronized state on the client.

Server parsing

Page search / searchParams values are strings for single keys and ordered arrays for repeated keys, including empty values. Names such as toString and hasOwnProperty are ordinary query data, not inherited JavaScript methods. Farm omits __proto__, constructor, and prototype when converting request search parameters to objects. This representation is shared by development rendering, production SSR, page-data navigation, and hydration.

src/app/search/page.tsx
import type { PagePropsSafe } from "@farm.js/core/query";
import { asInteger, asString, loadSearchParams } from "@farm.js/core/query/server";

export default async function SearchPage({ searchParams }: PagePropsSafe) {
  const query = await loadSearchParams(searchParams, {
    q: asString.withDefault!(""),
    page: asInteger.withDefault!(1),
  });

  return <pre>{JSON.stringify(query, null, 2)}</pre>;
}

Client query state

src/components/search-controls.tsx
"use client";

import { asString, useQueryState } from "@farm.js/core/query/client";

export function SearchControls() {
  const [q, setQ] = useQueryState("q", asString.withDefault!(""), {
    throttleMs: 150,
  });
  return <input value={q} onChange={(event) => setQ(event.target.value)} />;
}

throttleMs coalesces rapid writes to the same query key. Updates to different keys are composed against the latest URL, and returning a value to the current URL cancels its queued write. The latest edit to each key wins across hook instances and overlapping multi-key updates, even with different throttle durations or an immediate writer. Superseding one key keeps unrelated keys in an older batch queued. Cancelling the newer edit does not restore an older superseded value. Hook values update immediately while the URL write is queued. Inline parsers, inline parser maps, parent rerenders, and other consumers of the same keys preserve those pending values; memoizing parsers is not required. Each queued key keeps its draft when another key commits. Consumers sharing a key parse incoming edits with their own parser, including while a URL write is queued. Different parser types therefore retain their own output types and defaults; removing a key is parsed like an absent URL value. Multiple useQueryState writers receive each other's edits immediately, even when both write in the same event before a throttled URL commit. A writer ignores only its own draft notification; there is no time window during which peer edits are dropped. If a component changes the key or parser it passes to the hook, the returned value is immediately re-parsed from the current URL plus active queued values for that URL. A queued write is cancelled when its owning hook changes keys or unmounts. Navigation to a different URL also discards its pending edits, including in a persistent layout; returning to the old URL does not restore discarded drafts. Writes to different query keys still compose across Farm's own query updates.

Repeated keys have the same meaning during server rendering and in client hooks. For example, ?tag=react&tag=vite is read as both values by asArrayOf(asString).

When Farm's SPA router is installed, shallow query pushes participate in its normal history index. Back/forward blockers therefore receive the rendered query location and can restore a blocked traversal without inserting a duplicate entry.

Multiple query values

Use useQueryStates when several controls should update together. This keeps the browser URL as the source of shareable state for filters, pagination, and tabs. Changing the parser map replaces the returned object with exactly the newly declared keys.

useQueryStates receives drafts from both useQueryState and other useQueryStates instances immediately, before a throttled URL write. Each changed key is parsed with the receiving map's parser; unrelated local drafts are preserved, and the writer does not parse its own draft echo. Changing the parser map replaces its keyed subscriptions, and unmounting removes them. Receiving a peer's draft does not take ownership of its URL timer, so unmounting a reader does not cancel the writer's queued update.

src/components/product-filters.tsx
"use client";

import { asInteger, asString, useQueryStates } from "@farm.js/core/query/client";

export function ProductFilters() {
  const [filters, setFilters] = useQueryStates(
    {
      q: asString.withDefault!(""),
      page: asInteger.withDefault!(1),
      plan: asString,
    },
    {
      history: "replaceState",
      shallow: true,
    },
  );

  return (
    <form>
      <input
        value={filters.q}
        onChange={(event) => {
          setFilters({
            q: event.target.value,
            page: 1,
          });
        }}
      />
    </form>
  );
}

Route params

Use route param parsers when dynamic segments should be typed before they hit your data layer.

src/app/users/[id]/page.tsx
import type { PageProps } from "@farm.js/core";
import { asString, loadRouteParams } from "@farm.js/core/query/server";

export default async function UserPage({ params }: PageProps) {
  const { id } = await loadRouteParams(params, {
    id: asString,
  });

  return <main>User {id}</main>;
}

Pagination metadata

createPaginationMeta(searchParams, { totalItems, itemsPerPage }) returns a safe page, offset, limit, and next/previous flags for server-rendered lists. Missing, malformed, zero, negative, or unsafe page values fall back to page 1. totalItems must be a non-negative safe integer and itemsPerPage must be a positive safe integer; invalid configuration throws a RangeError.

Parser reference

ParserReads
asStringPlain strings.
asIntegerComplete, safe integer values for pagination and limits.
asFloatComplete finite decimal or exponent values.
asBooleanBoolean flags.
asArrayOf(parser)Repeated values serialized through another parser.
asJsonStructured JSON encoded in the URL.
asIsoDateCalendar-valid YYYY-MM-DD values.
asIsoDateTimeCalendar-valid ISO date-times with Z or numeric offset.

asArrayOf keeps its existing comma-separated format by default. When items can contain commas, opt in to the structured format so generated URLs round-trip those values:

const locations = asArrayOf(asString, { format: "structured" });

The item parser still decides how each value is normalized. asString trims surrounding whitespace and treats an empty string as missing. Use an exact string parser when those values are significant:

import { asArrayOf, createParser } from "@farm.js/core/query";

const exactString = createParser<string>({
  parse: (value) => value,
  serialize: (value) => value,
});
const labels = asArrayOf(exactString, { format: "structured" });

The structured parser still accepts ordinary comma URLs during migration and emits a versioned ~farm-array:v1: representation only when the comma format would lose information. Because the default parser never reserves that namespace, existing literal values such as ~farm-array:v1:["legacy"] retain their comma-format meaning.

Production notes

  • Parse query on the server before passing values to database queries.
  • Use defaults for values that should always be present in UI state.
  • Use replaceState for filters that change often, and pushState when each change should be browser-history navigable.
  • Keep large state out of the URL; store only the values users should be able to share.