Next.js router.push Query Params: push, replace, and the Round Trip
Copy searchParams into a mutable URLSearchParams, then decide push or replace. Three real writes: a dashboard tab, a validated ?next= redirect, a checkout hand-off.
To write query params in the Next.js App Router, copy the current params into a mutable URLSearchParams, set your keys, and push the result onto the pathname with router.push — the string form, since next/navigation has no object form. Use router.replace instead when the change is a view toggle rather than a navigation, and pass { scroll: false } so the page does not jump to the top.
That is the whole API. Everything that goes wrong afterwards is a decision, not a syntax error: push or replace, whether the server re-reads the param, and whether you trust what comes back out of the URL. This post works through the three places a production Next.js storefront writes query params — a dashboard tab, a post-login redirect, and a checkout hand-off — and what each one chose.
push vs replace is the decision nobody writes about
router.push adds a history entry. router.replace swaps the current one. For a param that represents a different thing to look at, push is right — the back button should undo it. For a param that mirrors UI state so the URL stays bookmarkable, push is wrong, and here is the concrete failure it causes:
// src/components/organisms/DashboardTabs.tsx — "use client"
const select = (id: DashboardTab) => {
setActiveTab(id);
router.replace(`/dashboard?tab=${id}`, { scroll: false });
};
Three tabs, and the tablist follows the WAI-ARIA pattern with automatic activation: arrow keys move focus and select in the same keystroke. A user sweeping left and right across three tabs with the arrow keys generates a selection per keypress. With push, ten keystrokes leave ten history entries, and the back button then walks backwards through tab states instead of leaving the dashboard. With replace, the URL is always current, always shareable, and the back button does what the user meant.
{ scroll: false } is the second half. Without it, Next.js scrolls to the top of the document on navigation — correct when you have gone somewhere, actively hostile when you have only switched a tab in place.
The rule that falls out: if the user would be surprised to need the back button to undo it, use replace. Tabs, sort order and an open panel are all replace; page 2 of a list, a selected product and a submitted search are all push.
Filters are the genuinely contested case, and this codebase does not resolve it one way. The filter control uses push, so the back button undoes a checkbox — defensible for a panel the shopper clicks a few times, and painful on a control that fires on every keystroke. The test is the interaction rate, not the kind of state: anything that can fire ten times in two seconds should be replace.
Writing multiple params without losing one
useSearchParams() returns a read-only URLSearchParams. You cannot set on it, and the fix is one constructor call:
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
export function useQueryParams() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
return (updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null) params.delete(key);
else params.set(key, value);
}
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
};
}
Three details earn their place. null deletes a key, because a filter cleared must leave the URL rather than linger as ?category=. The pathname is emitted bare when nothing is left, so a cleared view does not keep a trailing ?. And every change goes in one call: two router.push calls in a row each build from the same stale snapshot, so the second silently discards the first — the single most reported bug on this keyword.
One caveat worth knowing before you reach for the hook: useSearchParams() in a component that would otherwise be statically rendered opts that route into dynamic rendering. On this site every public page is static, so the one component that needs a param on mount reads it without the hook:
// src/components/molecules/BuyButton.tsx — inside the mount effect
const buy = new URLSearchParams(window.location.search).get("buy");
if (buy === tier && !autoOpened.current) {
autoOpened.current = true;
window.LemonSqueezy?.Url.Open(result.url);
}
That runs in an effect, so it is client-only by construction and costs the route nothing. It is the right tool for "read this once after hydration", and the wrong one for "re-render when the param changes".
The round trip: who reads what you wrote
A param you write is a param something reads, and in the App Router the reader is usually the server. The dashboard writes ?tab= from the client and reads it back on the server:
// src/app/dashboard/page.tsx
function isValidTab(value: string | undefined): value is DashboardTab {
return value === "overview" || value === "purchases" || value === "settings";
}
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ verification?: string; tab?: string }>;
}) {
const { verification, tab } = await searchParams;
// …
<DashboardTabs initialTab={isValidTab(tab) ? tab : "overview"} /* … */ />
Note what the type annotation does not do: tab?: string is a claim about the shape, not a guarantee about the value. The URL is an editable text field, so ?tab=<img src=x> arrives as happily as ?tab=settings. isValidTab is a type guard over a closed set of three literals — the narrowing and the validation are the same line of code. An as DashboardTab cast would have compiled and shipped the hostile value straight into a Record lookup.
The one param you must validate: a redirect target
The sharpest case in this codebase is a param that is written by a server redirect, carried through a login, and then handed to router.push:
// src/components/molecules/AuthCard.tsx — "use client"
useEffect(() => {
if (state?.ok && redirectTo) {
router.push(redirectTo);
}
}, [state, redirectTo, router]);
redirectTo originates in ?next=. Pushing it unchecked is an open redirect — an attacker sends /login?next=https://evil.example, the victim signs in for real, and lands on a credential-harvesting clone with a legitimate referrer. The guard is four lines:
// src/lib/validation.ts
const SAFE_NEXT_RE = /^\/(?![/\\])/;
export function safeNext(next: string | null | undefined): string {
return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}
Only a same-origin relative path passes. The negative lookahead rejects //evil.example, which browsers treat as protocol-relative, and /\evil.example — WHATWG URL parsing treats a leading backslash as a slash, so the backslash form is just as much an open redirect. That second case is in src/lib/__tests__/validation.test.ts as its own test, because it is the one a hand-rolled startsWith("/") check misses.
The same ?next= is what completes the checkout flow. A logged-out buyer's CTA links to /login?next=/pricing?buy=<tier>; after sign-in AuthCard pushes them back to /pricing?buy=pro, and BuyButton reads ?buy= on mount and opens the checkout overlay. Three components, one param, written and read by different halves of the app — which is exactly why the validation sits in a pure function with its own tests rather than at any one of the call sites.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
params.set is not a function | useSearchParams() is read-only | new URLSearchParams(searchParams.toString()) first |
| Second param change overwrites the first | Two pushes built from the same stale snapshot | Apply every update in one URLSearchParams, then one push |
| Page jumps to the top on every filter click | Default scroll-to-top on navigation | Pass { scroll: false } |
| Back button walks through UI states | push used for a view toggle | Use replace for tabs, filters and sort order |
| Server component does not see the new param | It is reading a stale render, or the route is static | await searchParams in the page; check the route is not statically optimised |
?flag=false behaves as true | It is the five-character string "false" | Compare explicitly: params.get("flag") === "true" |
Build warns about useSearchParams and prerendering | The hook opts the route into dynamic rendering | Wrap that component in <Suspense>, or read window.location.search in an effect |
router.push with an object does nothing | The object form is Pages Router only | Build the string yourself in the App Router |
FAQ
How do I add a query param with router.push in Next.js?
In the App Router, build the string:
const params = new URLSearchParams(searchParams.toString());
params.set("page", "2");
router.push(`${pathname}?${params.toString()}`);
In the Pages Router you can pass an object instead — router.push({ pathname, query: { ...router.query, page: 2 } }) — and that form does not exist in next/navigation, which is the single most common reason copied snippets silently do nothing.
What is the difference between router.push and router.replace for query params?
push adds a history entry, so the back button undoes the change; replace swaps the current entry, so the URL stays accurate without accumulating history. Use replace when the param mirrors UI state — the tab example above would otherwise fill the history stack a keypress at a time.
Does changing a query param re-run a Server Component?
Yes — a router.push/replace to a new URL on a dynamic route re-renders the server component with the new searchParams. It will not help you on a statically rendered route, which is why a static page that needs a param reads it on the client instead.
Can I update the URL without triggering a navigation at all?
window.history.replaceState does that, and it is appropriate for a value nothing re-reads, such as a scroll position. The cost is that Next.js's router does not know the URL changed, so no server component sees the new param and useSearchParams consumers will not update. Prefer router.replace with { scroll: false } unless you specifically want the router left out.
Templates in this post
ASoc Nexus (a smart-apps SaaS site with a dashboard mock), ASoc Nimbus (a cloud-software site with tiered pricing) and ASoc Nova (a crypto trading page with live price widgets) ship with the same URL-state conventions as the code above — view state in the URL, validated on the way back in.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the reading half of this, see Next.js search params and get query params server-side; for the filter case end to end, product filtering with searchParams.
