Skip to 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.

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:

  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.

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.

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.

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.

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
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
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.

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.

  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'));
  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.