Interview question
Synchronize filters and pagination with the URL
Makes list filters shareable and back-button friendly while resetting pagination deliberately.
TL;DR
Makes list filters shareable and back-button friendly while resetting pagination deliberately.
URL state, controlled filters, navigation, pagination reset, parsing, and shareable views.
Practice the problem like a real interview: restate, reason, implement, and test.
Implement filters for a Next.js App Router list page with search text q, status active|paused, and positive page number. The URL is the durable source of truth: a copied link must restore the view, filter changes must reset pagination, page changes must preserve filters, and browser Back/Forward must update the controls. Default values should be omitted so equivalent views have one clean URL.
Move to the linked follow-up, next path step, prerequisite, or deeper variant.
Practice the next layer of the same subject.
Opening /drills?q=cache&status=active&page=3 shows those exact controls and page. Changing status to Paused navigates to /drills?q=cache&status=paused, because page resets to one and page one is omitted. Submitting an empty search removes q. Back restores the earlier Active/page-three view and the search field follows the URL instead of keeping stale local text.
useSearchParams() as immutable and clone it before updates.all; invalid, fractional, zero, or negative page becomes 1.status=all, blank q, and page=1 from the canonical URL.q changes through Back, Forward, or external navigation.page before navigating.router.push for committed user choices so history represents meaningful views. Reserve replace for silent canonicalization or debounced drafts if the product chooses them."use client";
import { FormEvent, useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type Status = "all" | "active" | "paused";
function parseStatus(value: string | null): Status {
return value === "active" || value === "paused" ? value : "all";
}
function parsePage(value: string | null): number {
if (value === null || !/^\d+$/.test(value)) return 1;
const page = Number(value);
return Number.isSafeInteger(page) && page > 0 ? page : 1;
}
export function DrillFilters() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const query = searchParams.get("q")?.trim() ?? "";
const status = parseStatus(searchParams.get("status"));
const page = parsePage(searchParams.get("page"));
const [queryDraft, setQueryDraft] = useState(query);
useEffect(() => {
setQueryDraft(query);
}, [query]);
function navigate(mutator: (params: URLSearchParams) => void) {
const params = new URLSearchParams(searchParams.toString());
mutator(params);
const next = params.toString();
router.push(next ? `${pathname}?${next}` : pathname, { scroll: false });
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextQuery = queryDraft.trim();
navigate((params) => {
nextQuery ? params.set("q", nextQuery) : params.delete("q");
params.delete("page");
});
}
function changeStatus(nextStatus: Status) {
navigate((params) => {
nextStatus === "all"
? params.delete("status")
: params.set("status", nextStatus);
params.delete("page");
});
}
function changePage(nextPage: number) {
if (!Number.isSafeInteger(nextPage) || nextPage < 1) return;
navigate((params) => {
nextPage === 1
? params.delete("page")
: params.set("page", String(nextPage));
});
}
return (
<section aria-label="Drill filters">
<form onSubmit={submitSearch}>
<label htmlFor="drill-search">Search</label>
<input
id="drill-search"
type="search"
value={queryDraft}
onChange={(event) => setQueryDraft(event.target.value)}
/>
<button type="submit">Apply search</button>
</form>
<label htmlFor="drill-status">Status</label>
<select
id="drill-status"
value={status}
onChange={(event) => changeStatus(event.target.value as Status)}
>
<option value="all">Any status</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
<nav aria-label="Pagination">
<button disabled={page === 1} onClick={() => changePage(page - 1)}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => changePage(page + 1)}>Next</button>
</nav>
</section>
);
}
The page or server component can consume the same validated parameters to fetch data. Keeping parsing rules shared prevents the controls and the server query from disagreeing about what a URL means.
Parsing and updating a short query string is O(p) for p parameters, which is negligible here. The main trade-off is behavioral: URL state makes views shareable, reload-safe, observable, and compatible with history, but every committed change becomes navigation and may trigger server work. A submit-based search avoids noisy history. A debounced search can use replace for intermediate edits and push for explicit commits, but that policy must be clear. Parsing should ideally be shared with the server-side data loader so invalid values cannot produce two interpretations.
page=0, page=-2, page=1.5, an enormous unsafe integer, or nonnumeric input.&, or ?, which must round-trip through URLSearchParams encoding.useSearchParams may need an appropriate Suspense boundary in the surrounding Next.js tree.all and navigate to page one; verify both defaults are omitted.all, blank search, and page one were written instead of deleted.push was tied directly to input changes rather than a committed or carefully debounced action.Number(...) but not positivity, integer form, and safe range.Spot a weak answer, missing edge case, or clearer explanation? Send it in.