Skip to main content

Search

Search problems, hooks, and pages

Back to About

Shareable Filters

built with: nuqs

Filter state stored in the query string instead of React state, so the back button and a copied link both do the obvious thing.

The URL is the state

Held in `useState`, a filter selection is invisible to the rest of the world: refreshing loses it, the back button skips past it, and sending someone "the advanced async notes" means sending instructions instead of a link.

nuqs stores that state in the query string while giving it a `useState`-shaped API. `useQueryState("cat", { defaultValue: "all" })` reads and writes `?cat=`, with the default kept out of the URL so an unfiltered page stays clean.

Because it is genuinely the URL, browser history, refresh, deep links, and bookmarks all work without any extra code.

// src/components/content/filter-panel.tsx
const [q] = useQueryState('q', { defaultValue: '' });
const [cat] = useQueryState('cat', { defaultValue: 'all' });
const [diff] = useQueryState('diff', { defaultValue: 'all' });
const activeCount = (q ? 1 : 0) + (cat !== 'all' ? 1 : 0) + (diff !== 'all' ? 1 : 0);

One source of truth, two layouts

The filter panel renders a sticky sidebar rail on desktop and the exact same controls inside a slide-over sheet on mobile. Both render the same `NoteFilters` component.

Sharing state between two separately-mounted copies of a control would normally require lifting it into a common parent or a store. Here it needs neither — both read from the URL, so they cannot disagree. The active-filter count on the mobile trigger button is derived the same way.

Deep-linking into content

The same mechanism drives `?open=` on the notes and custom-hooks pages, which expands a specific item on load. Those pages read the parameter server-side from `searchParams`, which is what makes the deep link work on a cold load rather than only after hydration.

nuqs requires its adapter to be mounted; `NuqsAdapter` sits near the top of the provider tree in `providers.tsx`.

Important

Gotcha

Reading a query param server-side from `searchParams` opts the route into dynamic rendering. That is the deliberate trade here — deep links that work on first paint, in exchange for not statically pre-rendering those pages.

Where it lives in the repo

  • src/components/content/filter-panel.tsx
  • src/components/content/note-filters.tsx
  • src/components/content/notes-view.tsx
  • src/app/providers.tsx