paramour
Guides

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:

app/products/filter-summary.tsx
// @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 = (, { : () => . });
const page: SafeResult<number>
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" }:

components/product-badge.tsx
// @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 OrThrow variants. Throwing on pending would flash the error boundary on every statically-optimized page's first render; returning T | undefined would 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. (On getServerSideProps pages the first render is already populated, so pending never 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).;
Argument of type 'PagesRoute<"/legacy/[id]", { readonly id: Codec<number, "required", false, "single", boolean>; }, Record<never, never>>' is not assignable to parameter of type 'AnyAppRoute'. Type 'PagesRoute<"/legacy/[id]", { readonly id: Codec<number, "required", false, "single", boolean>; }, Record<never, never>>' is missing the following properties from type 'AppRoute<string, any, any>': parse, parseParams, parseSearch, safeParse, and 2 more.
}

Where next

On this page