Nov 12, 2026
Your filters should live in the URL
A shop owner filters the orders table to unpaid invoices, finds the one she needs, and copies the link to send to her accountant. The accountant opens it and sees every order, unfiltered. The filter never left the first browser — it lived in useState, and useState dies with the component.
This is the most common state mistake I see in dashboards, and I have made it myself. Filters, search terms, sort order, and the page number feel like component state, because they live next to the table. But they are not component state. They describe a view, and a view should have an address.
The URL is the only state that is shareable, bookmarkable, and refresh-safe for free. Put the filter there and three things fix themselves at once: refresh keeps your place, a shared link shows the same filtered results to whoever opens it, and the back button steps back through views instead of throwing you off the page entirely.
In Next.js the pattern is simple, and it starts on the server. The page reads the query string and fetches exactly the data it describes:
export default async function OrdersPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; page?: string }>;
}) {
const { status = "all", page = "1" } = await searchParams;
const orders = await getOrders({ status, page: Number(page) });
return <OrdersTable orders={orders} />;
}
In Next.js 15 searchParams is a Promise, so you await it. The page stays a Server Component — it fetches what the URL says and nothing else. The filter controls are Client Components, but they never hold the data. Their only job is to change the URL:
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function StatusFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function onChange(status: string) {
const params = new URLSearchParams(searchParams);
params.set("status", status);
params.set("page", "1"); // a new filter starts on page one
router.replace(`${pathname}?${params.toString()}`);
}
return (
<select value={searchParams.get("status") ?? "all"} onChange={(e) => onChange(e.target.value)}>
<option value="all">All orders</option>
<option value="unpaid">Unpaid</option>
<option value="paid">Paid</option>
</select>
);
}
Three details in that small component do most of the work. First, it seeds the new params from the current ones, so changing the status does not silently wipe the search term or the sort order. Second, it resets the page to 1 — a new filter usually shrinks the result set, and landing on page 4 of a 2-page list is an empty table that looks like a bug. Third, it uses router.replace, not router.push. Push adds a history entry per tweak, so the back button steps through every filter change; replace keeps one entry, and back leaves the page like the user expects.
If you are writing this by hand in more than one place, the boilerplate gets old fast: parse strings, validate them, keep defaults in sync on the server and the client. That is what nuqs is for. It gives you a useState-shaped hook over the URL:
import { useQueryState, parseAsString } from "nuqs";
const [status, setStatus] = useQueryState(
"status",
parseAsString.withDefault("all"),
);
Same behaviour — refresh-safe, shareable, typed — with the parsing handled for you. Setting the value to null removes the parameter from the URL entirely, which is the clean way to clear a filter. And on the server side, nuqs/server lets you parse the same params with the same parsers, so the server and the client can never disagree about what ?page=abc means.
A few honest limits. Not everything belongs in the URL. A form draft mid-edit, whether a dropdown is open, a hover state — that is ephemeral, and useState is still the right tool. The rule I use: if the user would expect a refresh to show the same view, put it in the URL; if not, do not. Validate what comes in, too — the query string is user input, and ?page=abc or ?status=whatever should fall back to a default, not crash the query. And watch the Suspense gotcha: a component calling useSearchParams() must sit inside a <Suspense> boundary, or the production build fails. That one cost me an afternoon the first time.
One more thing the URL buys you that no state library does: debugging. When a user reports a broken view, they can paste the link, and you open the exact state they are seeing. No "can you tell me what filters you had on" — the URL is the bug report.
The table is the product. The URL is the address of what the user is looking at. Give every view one, and the link a user copies will finally show what they actually saw.