useQueryState). This means every filter a user sets — search term, date range, status filter, line-of-business selector — survives navigation, page refresh, and can be shared as a link that opens to the exact filtered view.
Tip:nuqsis a small, zero-dependency library (< 2 KB) that maps React state to URL search params with no boilerplate. It is the only way to store filter state in OpenInsure portals —useStatefor filter values is a bug.
Why the URL is the right place for filter state
RawuseState for filters causes three concrete user-facing bugs:
- Navigate away → filters reset — The user builds a filtered view, clicks into a record, hits back, and lands on the unfiltered default. They have to rebuild their query.
- Refresh → filters reset — Same problem. Every refresh destroys work.
- Can’t share a view — “Send me that filtered list” is not possible.
Setup — NuqsAdapter
Each Next.js app needsNuqsAdapter wrapped around its children in providers.tsx. This is a one-time setup per app.
app/providers.tsx
Usage — useQueryState
useState. The component API is identical — [value, setValue] — and it works with all existing controlled inputs and Select components unchanged.
Wiring to TanStack Query re-fetches
Include the URL-bound value in thequeryKey. When the filter changes, the URL updates, the component re-renders, and TanStack Query automatically re-fetches with the new key:
useQueryState for URL persistence — they just aren’t included in queryKey.
URL conventions
Carrier Portal
| Page | Param | Values | Effect |
|---|---|---|---|
/audit | ?category= | all, program, claim, policy, auth, bordereaux | Triggers API re-fetch |
/audit | ?severity= | all, info, warning, error, critical | Client-side filter |
/audit | ?q= | any string | Client-side search |
/claims/analytics | ?range= | 30d, 90d, 6m, 12m, ytd | Controls chart period |
/claims/analytics | ?lob= | all, auto, gl, wc, prop | Triggers API re-fetch |
/financials | ?range= | 30d, 90d, 6m, 12m, ytd | Controls chart period |
UW Workbench
| Page | Param | Values | Effect |
|---|---|---|---|
/triage | ?priorities= | comma-separated: high, medium, low | Client-side filter |
/triage | ?statuses= | comma-separated status values | Client-side filter |
/triage | ?assignedTo= | user ID or '' | Client-side filter |
/submissions | ?q= | any string | Client-side search |
/submissions | ?statuses= | comma-separated status values | Triggers re-fetch |
/policies | ?q= | any string | Client-side search |
/policies | ?statuses= | comma-separated status values | Triggers re-fetch |
Primitives reference
| Primitive | Import | Use case |
|---|---|---|
parseAsString | nuqs | Single-value filters (status, category, search) |
parseAsArrayOf(parseAsString) | nuqs | Multi-select filters (statuses[], priorities[]) |
parseAsInteger | nuqs | Numeric params (page, limit) |
parseAsBoolean | nuqs | Toggles |
parseAsIsoDate | nuqs | Date range pickers |
.withDefault(value) to avoid null returns on first load.
CSV export — filter-aware
When a page exports data, the export should respect the current filter state. Since the filtered list is already computed, pass it directly to the export function:audit/page.tsx
Adding nuqs to a new page
- Import
useQueryStateand the rightparseAs*primitive. - Replace
useStatecalls for filter values withuseQueryState. - Keep the value in
queryKeyif it drives an API call. - Done — no adapter changes, no router wiring, no context setup.
Caution: Do not mixuseStateanduseQueryStatefor the same filter. If a filter is URL-persistent, it must only useuseQueryState. Mixing the two creates split-brain state where the URL and component disagree after navigation.
Adding nuqs to a new portal
- Add
nuqsto the app’spackage.jsondependencies (it is already a workspace root dep). - Import
NuqsAdapterfromnuqs/adapters/next/appin the app’sproviders.tsx. - Wrap children with
<NuqsAdapter>. - All
useQueryStatecalls in the app will work automatically.
apps/workbench) use nuqs/adapters/react instead.