nuqs

@beignet/nuqs connects contract query schemas to URL-backed state. Use it for search, filters, tabs, sorting, and pagination when the URL should reflect the current view.

bun add @beignet/nuqs @beignet/react-query @tanstack/react-query nuqs

Setup

import { createNuqs } from "@beignet/nuqs";

export const nq = createNuqs();

Bind the contract builder directly with nq(contract). The helper reads the contract query schema and keeps URL state aligned with the same input shape used by the client.

In Next.js App Router, mount the NuqsAdapter once:

import { NuqsAdapter } from "@beignet/nuqs/next/app";

export function Providers({ children }: { children: React.ReactNode }) {
  return <NuqsAdapter>{children}</NuqsAdapter>;
}

URL-backed filters

"use client";

import { useQuery } from "@tanstack/react-query";
import { parseAsString, parseAsStringLiteral } from "nuqs";
import { listContacts } from "@/features/contacts/contracts";
import { nq, rq } from "@/client";

const contactsSearch = nq(listContacts).query({
  parsers: {
    search: parseAsString,
    group: parseAsStringLiteral(["personal", "work", "family", "other"]),
  },
  history: "replace",
});

function ContactsPage() {
  const [filters, setFilters] = contactsSearch.useState();

  const query = useQuery(
    contactsSearch.toQueryOptions(rq(listContacts), filters, {
      query: { limit: 50, offset: 0 },
    }),
  );

  return null;
}

toQueryOptions(...) composes with @beignet/react-query, so URL state and query input stay aligned with the same contract.

Query transport

Nuqs controls the browser page URL. The contract's query transport controls the API request made by toQueryOptions(...); those are separate representations. toQuery(...) preserves parsed values, including Date and plain objects.

For example, declare a date and a flat object on a search contract:

import { defineContractGroup, defineQueryTransport, query } from "@beignet/core/contracts";
import { z } from "zod";

const filterSchema = z.object({ owner: z.string(), archived: z.boolean() });
export const searchIssues = defineContractGroup()
  .get("/api/issues")
  .query(
    z.object({ createdAfter: z.date().optional(), filter: filterSchema.optional() }),
    defineQueryTransport({
      createdAfter: query.date(),
      filter: query.deepObject({ owner: query.string(), archived: query.boolean() }),
    }),
  )
  .responses({ 200: z.object({ ids: z.array(z.string()) }) });

Bind nuqs parsers to that same shape:

import { parseAsIsoDateTime, parseAsJson } from "nuqs";

const issueSearch = nq(searchIssues).query({
  parsers: {
    createdAfter: parseAsIsoDateTime,
    filter: parseAsJson(filterSchema),
  },
});

parseAsJson serializes the object as JSON in the page URL. When the state is passed to the API client, query.deepObject(...) instead produces bracketed parameters such as filter[owner]=user_1&filter[archived]=false. query.date() sends a valid Date as an ISO-8601 value. The typed client rejects invalid dates, nested objects, and members not declared by the transport with INVALID_QUERY_PARAM.

The example defines the client contract; register a matching route and use case when adding this search endpoint to your app. See Query transport for empty-value handling and other supported representations.

Query helper options

nq(contract).query(config) requires one property: parsers, a map of nuqs parsers keyed by the contract's query schema keys. Parser keys are checked against the contract query shape, and you only declare parsers for the keys the URL should own — other query params can stay in normal component state or static query input.

Everything else in config is an optional nuqs useQueryStates option passed through to the hook: history ("replace" by default, or "push" to create history entries), shallow, scroll, clearOnDefault, limitUrlUpdates, startTransition, and urlKeys for renaming search params. See the nuqs options reference for what each one does. useState(options) accepts the same options as per-call overrides.