Hooks
useRouteParams and useSearch for the App and Pages routers.
Client components read the URL through two hooks — useRouteParams and
useSearch — that take the same route object as everything else. There are
two hook modules, one per router, because the routers genuinely differ; the
compiler won't let you import from the wrong one.
App Router
Import from @paramour-js/next/app. App-Router params are synchronously
available on the client, so there is no loading state and results are
SSR-consistent — the hooks return a two-arm SafeResult:
// @filename: app/products/route.def.ts
import { , } from "paramour";
export const = ("/products", {
: { : .().(1), : .().() },
});
// @filename: app/products/filter-summary.tsx
"use client";
import { } from "@paramour-js/next/app";
import { } from "./route.def";
export function () {
const = ();
if (. === "error") {
return < ="alert">{..}</>;
}
return (
<>
Page {..}
{.. && <> — “{..}”</>}
</>
);
}The status === "error" guard is the pattern to copy: a user can put
anything in the address bar, and a hand-mangled URL should render a fallback,
not crash the tree. After the guard, search.data is fully narrowed.
The OrThrow variants
useRouteParamsOrThrow / useSearchOrThrow return the decoded value
directly and throw the decode error in render, to your nearest client error
boundary. Reach for them when the route is only reachable through links you
built with href — then a malformed URL genuinely is exceptional, and
per-component fallbacks would be noise. If the URL is something users edit
or share (filters, search), prefer the SafeResult form.
Selecting a slice
Every hook takes an optional { select } that projects the decoded value,
with equality checking so an unchanged selection keeps its previous
reference while other params churn:
// @filename: app/products/route.def.ts
import { , } from "paramour";
export const = ("/products", {
: { : .().(1), : .().() },
});
// @filename: app/products/page-badge.tsx
"use client";
import { } from "@paramour-js/next/app";
import { } from "./route.def";
export function () {
const page = (, { : () => . });
return . === "success" ? <>p. {.}</> : null;
}The selector runs on the success arm only, and result equality is Object.is
(pass equality: "shallow" for object selections). Under the hood the hooks
are also stabilized on the declared slice of the URL: a change that only
touches keys the route doesn't own — ?utm_source= churn — returns the
previous result by identity, so memoized children don't re-render.
Pages Router
Import from @paramour-js/next/pages, with routes built by
definePagesRoute. The Pages Router has a platform fact the App Router
doesn't: on a statically-optimized page, router.query is empty until
hydration completes. Instead of papering over it, the result type adds a
third arm — RouterResult is SafeResult plus { status: "pending" }:
// @filename: lib/routes.ts
import { , } from "paramour";
export const = ("/products/[id]", {
: { : .() },
});
// @filename: components/product-badge.tsx
import { } from "@paramour-js/next/pages";
import { } from "../lib/routes";
export function () {
const = ();
switch (.) {
case "error":
return < ="alert">{..}</>;
case "pending":
return <>Loading…</>;
case "success":
return <>Product #{..}</>;
}
}Two deliberate differences from the App module:
- No
OrThrowvariants. Throwing onpendingwould flash the error boundary on every statically-optimized page's first render; returningT | undefinedwould make the name a lie. The three-state union forcing the check is the design. - No
"use client"requirement — that's an App Router concept; these hooks are ordinary Pages-bundle code. (OngetServerSidePropspages the first render is already populated, sopendingnever surfaces there.)
Server-side, Pages routes parse the data-fetching context synchronously:
// @filename: lib/routes.ts
import { , } from "paramour";
export const = ("/products/[id]", {
: { : .() },
: { : .().() },
});
// @filename: pages/products/[id].tsx
import type { } from "next";
import { } from "../../lib/routes";
export const = (async () => {
const = .();
if (. === "error") return { : true };
const { , } = .;
return { : { : ., : . ?? null } };
}) satisfies ;
export default function ({ }: { : number }) {
return <>Product #{}</>;
}The wrong-router mistake doesn't compile
Each hook module is gated to its router's route brand. Handing an App hook a Pages route (or vice versa) is a type error at the call site — not a hydration mystery three components deep:
// @filename: lib/routes.ts
import { , } from "paramour";
export const = ("/legacy/[id]", {
: { : .() },
});
// @filename: app/oops.tsx
"use client";
import { } from "@paramour-js/next/app";
import { } from "../lib/routes";
export function () {
return (legacyRoute).;}Where next
- Search params guide — declaring the configs these hooks decode.
- nuqs adapter — writing search state from the client, derived from the same codecs.
- Devtools panel — watch every hook decode live.