paramour
Guides

nuqs Adapter

Derive nuqs parsers from a route's search codecs.

nuqs is the standard tool for writing search-param state from client components — useQueryState(s) with batched updates, history control, and shallow routing. What it asks of you is a parser per key. @paramour-js/nuqs derives those parsers from the codecs your route already declares, so URL state is never defined twice.

npm install @paramour-js/nuqs nuqs

A filter panel from a route

nuqsParsers(route) turns a route's whole search config into an ordinary nuqs parser map:

app/products/filter-panel.tsx
// @filename: app/products/route.def.ts
import { ,  } from "paramour";

export const  = ("/products", {
  : {
    : .().(1),
    : .().(),
    : .(),
  },
});

// @filename: app/products/filter-panel.tsx
"use client";

import {  } from "@paramour-js/nuqs";
import {  } from "nuqs";

import {  } from "./route.def";

export function () {
  const [{ , ,  }, ] = (
    (),
  );

  return (
    <>
      <
        ={() => void ({ : 1, : .. })}
        ={ ?? ""}
      />
      < ={() => void ({ :  + 1 })}>
        next page ({.} tags active)
      </>
    </>
  );
}

Everything the codecs declared carries over: page reads non-nullable (its .default(1) became withDefault), q reads string | null (nuqs's spelling of absent), tags is a repeated-key multi-parser that reads [] when absent, and every value round-trips through the same wire format your server parse expects. When the route's config changes, this component's types change with it.

The output is ordinary nuqs currency — withOptions, createSerializer, createLoader, and the server cache all compose untouched. The adapter imports from nuqs/server internally, so deriving parsers is safe in server code too.

Single-codec derivation

nuqsParser(codec) is the one-key form, useful when a component owns a single param:

// @filename: app/products/route.def.ts
import { ,  } from "paramour";

export const  = ("/products", {
  : { : .().(1), : .().() },
});

// @filename: app/products/search-box.tsx
"use client";

import {  } from "@paramour-js/nuqs";
import {  } from "nuqs";
import {  } from "paramour";

export function () {
  const [, ] = ("q", (.().()));

  return < ={() => void (..)} ={ ?? ""} />;
}

The derivation rules

The adapter reads presence and defaults off the codec so the nuqs reading matches core's decode:

Codec shapenuqs parser
required or .optional()nullable read (null = absent)
.default(value)withDefault(value) — non-nullable read
.default(() => value) (factory)nullable — a frozen default would lie
.catch(fallback)malformed reads recover to the fallback
.optional().catch(undefined)malformed reads recover to null (absent)
p.array(…)multi (repeated-key) parser, [] when absent

What refuses to compile

nuqs reserves null to mean "absent or unparseable" — so a codec whose legitimate output includes null has no faithful nuqs twin, and the adapter rejects it at the call site with the reason in the error, rather than silently conflating your null with nuqs's:

import {  } from "@paramour-js/nuqs";
import {  } from "paramour";

const  = .<null | string>({
  : () => ( === "null" ? null : ),
  : () =>  ?? "null",
});

(maybe);
Argument of type 'Codec<string | null, "required", false, "single", boolean>' is not assignable to parameter of type 'Codec<string | null, "required", false, "single", boolean> & NoNuqsTwin<"codec output includes null, which nuqs reserves for absent/unparseable">'. Property '[noTwinReason]' is missing in type 'Codec<string | null, "required", false, "single", boolean>' but required in type 'NoNuqsTwin<"codec output includes null, which nuqs reserves for absent/unparseable">'.

rawSearch routes are rejected the same way (there are no per-key codecs to derive from), as are search-less routes. Plain-JavaScript callers get the same judgments as loud runtime errors.

Defaults computed at render time

A value-form .default() is fixed when the route is defined. When the default depends on the clock or on props — "this week" for a date range — the route can't declare it. Keep the key .optional() and add the default to the derived parser with nuqs's own withDefault, inside the component:

"use client";

import { nuqsParsers } from "@paramour-js/nuqs";
import { useQueryStates } from "nuqs";
import { useMemo } from "react";

import { gridRoute } from "./route.def";

const gridParsers = nuqsParsers(gridRoute); // from: p.string().optional(), …

export function useGridRange() {
  const defaults = useMemo(() => currentWeek(), []);
  const parsers = useMemo(
    () => ({
      ...gridParsers,
      from: gridParsers.from.withDefault(defaults.from),
      to: gridParsers.to.withDefault(defaults.to),
    }),
    [defaults],
  );
  return useQueryStates(parsers);
}

The derived parser keeps its wire-form eq, so nuqs's clearOnDefault drops from/to from the URL whenever they equal this render's default. Your setters pass plain values, with no value === default ? null : value logic, and reads are non-nullable. On the server the key is still .optional(), so apply the same default where you parse the route.

Keeping URLs nuqs already wrote

p.csv is nuqs-compatible for lists of non-empty elements that contain no commas. nuqs's parseAsArrayOf escapes an in-element comma as %2C and drops elements that fail to parse, while p.csv rejects the comma and fails the whole key. If you are moving off hand-written nuqs parsers and existing bookmarks must keep working, use nuqsArrayOf, which reads and writes nuqs's format exactly:

import { nuqsArrayOf } from "@paramour-js/nuqs";
import { defineAppRoute, p } from "paramour";

export const reportRoute = defineAppRoute("/report", {
  search: {
    clients: nuqsArrayOf().default([]), // "Acme, Inc." fits
    types: nuqsArrayOf(p.enum(["client", "internal"])).default([]),
  },
});

Server and client, one contract

The point of deriving rather than declaring: your server components parse with the route, your client components set state through parsers derived from the route, and standardSearchSchema exports the same contract to tRPC and friends. One search config, every consumer.

On this page