Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

SymptomCauseFix
params.set is not a functionuseSearchParams() is read-onlynew URLSearchParams(searchParams.toString()) first
Second param change overwrites the firstTwo pushes built from the same stale snapshotApply every update in one URLSearchParams, then one push
Page jumps to the top on every filter clickDefault scroll-to-top on navigationPass { scroll: false }
Back button walks through UI statespush used for a view toggleUse replace for tabs, filters and sort order
Server component does not see the new paramIt is reading a stale render, or the route is staticawait searchParams in the page; check the route is not statically optimised
?flag=false behaves as trueIt is the five-character string "false"Compare explicitly: params.get("flag") === "true"
Build warns about useSearchParams and prerenderingThe hook opts the route into dynamic renderingWrap that component in <Suspense>, or read window.location.search in an effect
router.push with an object does nothingThe object form is Pages Router onlyBuild 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.

Keep reading

Tutorial12 min read

Next.js Routing: 27 Page Files, 412 URLs, Three Conventions Unused

A census of one production app/ tree: which of the nine reserved filenames actually earn a file, and the 40 links, 13 redirects and 2 routers that move between them.

Read more
Tutorial10 min read

An RSS Feed in the App Router, Without an RSS Library

One route file, force-static, and eight lines of helpers. RFC-822 dates, five escaped characters, and the absolute-URL bug that reached our production markup.

Read more
Tutorial12 min read

Building a SaaS Landing Page in Next.js 16 That Loads Fast

Static rendering, LCP on the hero, and a small client bundle — plus the accessibility bugs our own Lighthouse audit caught that code review missed.

Read more