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.
nuqs is 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 — useState for filter values is a bug.
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
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
import { parseAsString, useQueryState } from 'nuqs';
// String filter with a default value
const [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
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
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 |
Always call .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:
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
- 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.
// Before
const [status, setStatus] = useState('all');
// After — one import, one change
import { parseAsString, useQueryState } from 'nuqs';
const [status, setStatus] = useQueryState('status', parseAsString.withDefault('all'));
Do not mix useState and useQueryState for the same filter. If a filter is URL-persistent, it
must only use useQueryState. 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.
For Vite-based SPAs (e.g. apps/workbench) use nuqs/adapters/react instead.