URL-synced filters for a PR list that actually scales
searchParams as source of truth, 200ms-debounced URL writes, server-side Prisma filtering, a clear button that doesn't flash, and why client-side filtering fell over at 400 PRs.

Every project eventually accumulates hundreds of pull requests. The dashboard’s PR list became the page I opened most and the page I was most frustrated by, because there was no way to find anything. I needed filters — by status (open, merged, closed, failed) and by free-text search on branch name / PR title / feedback ID. Easy feature, surprisingly deep rabbit hole of UX decisions.
URL as the source of truth
The first rule: filter state lives in the URL, not in React state. If a customer finds an interesting slice of PRs, they should be able to copy the URL, paste it in Slack, and have a teammate land on the exact same view. No “let me screenshot it” workflow, no “it’s the fourth tab down.”
In Next.js App Router, that means searchParams on a server component. The page reads them, builds a Prisma where clause, and renders the filtered list on the server. No client-side fetching, no loading spinners, no hydration mismatch. The client is involved only for the input components that write back to the URL.
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ q?: string; status?: string }>;
}
export default async function PrsPage({ params, searchParams }: Props) {
const { id } = await params;
const { q = "", status = "all" } = await searchParams;
const where: Prisma.PullRequestWhereInput = { projectId: id };
if (status !== "all") where.status = status as PrStatus;
const trimmed = q.trim();
if (trimmed) {
const prNumber = Number.parseInt(trimmed, 10);
where.OR = [
{ feedbackId: { startsWith: trimmed } },
{ branchName: { contains: trimmed, mode: "insensitive" } },
{ feedback: { content: { contains: trimmed, mode: "insensitive" } } },
...(Number.isFinite(prNumber) ? [{ githubPrNumber: prNumber }] : []),
];
}
const prs = await prisma.pullRequest.findMany({ where, include: { feedback: true } });
return <PrList prs={prs} />;
}Debounced writes, not debounced renders
The filter input is a client component. A naive implementation pushes a new URL on every keystroke, which re-runs the server component and, at ~200 PRs, flickers. The fix is to debounce the URL write but render the input without delay:
"use client";
export default function PrFilter({ defaultQuery, defaultStatus }: Props) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [q, setQ] = useState(defaultQuery);
useEffect(() => {
const t = setTimeout(() => {
const next = new URLSearchParams(params);
if (q) next.set("q", q);
else next.delete("q");
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
}, 200);
return () => clearTimeout(t);
}, [q]);
return <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search PRs or feedback..." />;
}200ms is the sweet spot. Below that the server component re-renders faster than the user can type; above that, the list feels stale. router.replace (not push) avoids polluting the back button. scroll: false is the magic option that keeps the page from jumping to the top on every keystroke.
Clear button that doesn’t flash
When any filter is active we show a Clear button. Pre-first-pass, it would flash in for 200ms after clearing the input because the parent re-renders before the URL writes back. Fix: the Clear button reads live state, not URL state, so it hides the moment the user types the delete.
Status dropdown semantics
Status is its own param because it maps to an enum, not a free-text search. “All” clears the key entirely rather than setting status=all, which keeps URLs clean when no filter is applied. It’s a small aesthetic choice, but it matters for the shareability goal — the canonical unfiltered URL is /prs, not /prs?q=&status=all.
What we got wrong
First version did the search client-side — fetched all PRs, filtered in memory. It worked beautifully on the demo project with 8 PRs. It fell over at ~400 PRs because the server was shipping a 2MB payload on every page load. Moving filtering server-side was a 2-hour rewrite and a 20× payload shrink. Classic lesson: “client-side filter” is a fine prototype and almost always wrong in production.
Small feature, useful daily, took about an afternoon to ship properly. The kind of feature that’s easy to skip and impossible to miss once it’s there.
Try FeedbackIQ
Drop a widget on your site, ship PRs from feedback
Claude reads the report, writes the fix, opens the PR on your repo. Dedupe with pgvector so the backlog doesn’t drown in duplicates.
Start for free