URL State — nuqs
Dashboard pages in OpenInsure store filter and view state in the URL via nuqs (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.
Why the URL is the right place for filter state
Section titled “Why the URL is the right place for filter state”Raw useState 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.
URL params fix all three with zero extra effort from the user.
Setup — NuqsAdapter
Section titled “Setup — NuqsAdapter”Each Next.js app needs NuqsAdapter wrapped around its children in providers.tsx. This is a one-time setup per app.
import { NuqsAdapter } from 'nuqs/adapters/next/app';
export function Providers({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider> <NuqsAdapter>{children}</NuqsAdapter> </QueryClientProvider> );}No configuration needed. The adapter handles serialization, history mode, and shallow routing for you.
Usage — useQueryState
Section titled “Usage — useQueryState”import { parseAsString, useQueryState } from 'nuqs';
// String filter with a default valueconst [status, setStatus] = useQueryState('status', parseAsString.withDefault('all'));
// Multi-value filter (array)import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';const [statuses, setStatuses] = useQueryState( 'statuses', parseAsArrayOf(parseAsString).withDefault([]));Drop-in replacement for 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
Section titled “Wiring to TanStack Query re-fetches”Include the URL-bound value in the queryKey. When the filter changes, the URL updates, the component re-renders, and TanStack Query automatically re-fetches with the new key:
const [category, setCategory] = useQueryState('category', parseAsString.withDefault('all'));
const { data } = useQuery({ queryKey: ['audit-events', category], // <-- nuqs value here queryFn: async () => { const params = new URLSearchParams(); if (category !== 'all') params.set('resource_type', category); const res = await fetch(`/api/v1/audit?${params}`); return res.json(); },});Client-side filters (search text, severity) that don’t need a re-fetch can still use useQueryState for URL persistence — they just aren’t included in queryKey.
URL conventions
Section titled “URL conventions”Carrier Portal
Section titled “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
Section titled “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
Section titled “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 |
Always call .withDefault(value) to avoid null returns on first load.
CSV export — filter-aware
Section titled “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:
function exportToCsv(events: AuditEvent[]) { const rows = events.map((e) => [ e.timestamp, e.actor.name, e.action, e.resource.type, e.resource.name, e.severity, e.status, ].join(',') ); const blob = new Blob( [['timestamp,actor,action,resource_type,resource_name,severity,status', ...rows].join('\n')], { type: 'text/csv' } ); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'audit-log.csv'; a.click();}
// In JSX — receives the already-filtered array, not the full dataset<Button onClick={() => exportToCsv(filteredEvents)}>Export CSV</Button>;The exported file always matches what the user sees, not the raw unfiltered API response.
Adding nuqs to a new page
Section titled “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.
// Beforeconst [status, setStatus] = useState('all');
// After — one import, one changeimport { parseAsString, useQueryState } from 'nuqs';const [status, setStatus] = useQueryState('status', parseAsString.withDefault('all'));Adding nuqs to a new portal
Section titled “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.
For Vite-based SPAs (e.g. apps/workbench) use nuqs/adapters/react instead.