Live demo: listing-demo-queryparams-esuv.vercel.app
Source code: github.com/ale-grosselle/listing-demo-queryparams
Every time I join a new frontend project, I find the same thing: a Redux store (or Zustand, or Jotai) holding the active filters for the listing page.
Brand, price range, region, sort order, all living in a client-side store, carefully synced back to the URL via a dedicated component that someone wrote and everyone is afraid to touch.
I get it, It feels natural: filters change, something needs to react, a store is the obvious tool.
But it is the wrong tool, and in Next.js App Router, you do not need it at all: The URL Is Already a State Manager!
The URL is persistent, shareable, bookmarkable, and browser-history-aware. It already does everything a filter store needs to do.
How It Works
The Server Reads Filter State Directly
The page component is an async Server Component:
it receives search params as a plain object, no store, no hooks, no hydration:
// src/app/listing/page.tsx
interface ListingPageProps {
searchParams: Promise<ListingSearchParams>;
}
export default async function ListingPage({ searchParams }: ListingPageProps) {
const params = await searchParams;
// params = { brand: 'fiat', region: 'lazio', price_min: '5000' }
const [result, filtersConfig] = await Promise.all([
fetchListings(params),
fetchFiltersConfig(params.category),
]);
return <ListingLayout result={result} filtersConfig={filtersConfig} params={params} />;
}
Every request reads the URL, fetches exactly what is needed, and renders. No store to populate, no hydration to manage.
The Client Writes Filter State Directly
When a user picks a filter, the Client Component reads the current URL params, updates the relevant key, and pushes the new URL.
No action, no reducer, no dispatch:
// src/app/listing/components/FilterControl/index.tsx
'use client';
export function FilterControl({ group, currentValue }: FilterControlProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const handleChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(group.key, value);
} else {
params.delete(group.key);
}
params.delete('page'); // reset pagination on filter change
startTransition(() => {
router.push(`?${params.toString()}`, { scroll: false });
});
};
return (
<select
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
disabled={isPending}
>
<option value="">All</option>
{group.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
}
That is the entire filter interaction.
useTransition keeps the UI responsive while the server re-renders, isPending tells you when an update is in flight, replacing the isUpdating flag you would have stored in Redux.
Back and forward navigation work for free. The browser owns the history.
What About Analytics?
This is the one genuinely tricky part. window.dataLayer is client-only, but the items that need to be reported arrive as Server Component output.
The solution is a thin Client Component that receives the rendered state as props and fires the event in a useEffect:
// src/app/listing/page.tsx
<ListingAnalytics
filters={params}
items={result.items}
total={result.total}
page={result.page}
/>
// src/app/listing/components/ListingAnalytics/index.tsx
'use client';
export function ListingAnalytics({ filters, items, total, page }: Props) {
const lastKeyRef = useRef<string | null>(null);
useEffect(() => {
const key = JSON.stringify({ filters, total, page });
if (key === lastKeyRef.current) return; // prevent duplicate events
lastKeyRef.current = key;
window.dataLayer = window.dataLayer ?? [];
window.dataLayer.push({
event: 'listing_updated',
listing: { filters, items, total, page },
});
}, [filters, items, total, page]);
return null;
}
Live demo: listing-demo-queryparams-esuv.vercel.app
Source code: github.com/ale-grosselle/listing-demo-queryparams


Top comments (1)
You should be very careful with everything you get from params. For example, in your case, this link should return a 404: https://listing-demo-queryparams-esuv.vercel.app/?category=cars&brand=noname&price_max=10kggold&fuel=juice. Validate everything twice: on the client and on the server.
Also, we already have libraries for this. If TanStack Router isn’t your choice and you prefer Next.js, then at least we have nuqs.dev/. I built a hook for this in the past to work with state, with Zod in the middle, and used it in a couple of projects, but then I decided it was easier to use a library because there are a lot of nuances