Skip to main content

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.

tip

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:

  1. 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.
  2. Refresh → filters reset — Same problem. Every refresh destroys work.
  3. 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.

app/providers.tsx
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

PageParamValuesEffect
/audit?category=all, program, claim, policy, auth, bordereauxTriggers API re-fetch
/audit?severity=all, info, warning, error, criticalClient-side filter
/audit?q=any stringClient-side search
/claims/analytics?range=30d, 90d, 6m, 12m, ytdControls chart period
/claims/analytics?lob=all, auto, gl, wc, propTriggers API re-fetch
/financials?range=30d, 90d, 6m, 12m, ytdControls chart period

UW Workbench

PageParamValuesEffect
/triage?priorities=comma-separated: high, medium, lowClient-side filter
/triage?statuses=comma-separated status valuesClient-side filter
/triage?assignedTo=user ID or ''Client-side filter
/submissions?q=any stringClient-side search
/submissions?statuses=comma-separated status valuesTriggers re-fetch
/policies?q=any stringClient-side search
/policies?statuses=comma-separated status valuesTriggers re-fetch

Primitives reference

PrimitiveImportUse case
parseAsStringnuqsSingle-value filters (status, category, search)
parseAsArrayOf(parseAsString)nuqsMulti-select filters (statuses[], priorities[])
parseAsIntegernuqsNumeric params (page, limit)
parseAsBooleannuqsToggles
parseAsIsoDatenuqsDate 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:

audit/page.tsx
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

  1. Import useQueryState and the right parseAs* primitive.
  2. Replace useState calls for filter values with useQueryState.
  3. Keep the value in queryKey if it drives an API call.
  4. 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'));
caution

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

  1. Add nuqs to the app's package.json dependencies (it is already a workspace root dep).
  2. Import NuqsAdapter from nuqs/adapters/next/app in the app's providers.tsx.
  3. Wrap children with <NuqsAdapter>.
  4. All useQueryState calls in the app will work automatically.

For Vite-based SPAs (e.g. apps/workbench) use nuqs/adapters/react instead.