Type something to search...
Next.js useRouter

Next.js useRouter

Most navigation in a Next.js app doesn't need any JavaScript at all — you render a <Link>, the framework prefetches it, and clicking it just works. But the moment navigation needs to happen as a side effect of something else — a form finishes submitting, a modal's confirm button gets clicked, an async operation resolves — you need to trigger that navigation from code. That's what useRouter is for.

It's a small hook with a deceptively large surface area once you start digging into what each method actually does under the hood, especially refresh(), which behaves nothing like a browser refresh, and bfcacheId, a newer addition most tutorials haven't caught up to yet. This article covers the full API, what each method does and doesn't invalidate, and the mistakes that trip people up in production.

Importing useRouter

useRouter comes from next/navigation, not next/router. This trips up nearly everyone migrating from the Pages Router, where next/router was the only option. In the App Router, next/router doesn't exist for this purpose — the import path itself is your signal that you're using the right mental model.

"use client";

import { useRouter } from "next/navigation";

export default function Page() {
  const router = useRouter();

  return (
    <button type="button" onClick={() => router.push("/dashboard")}>
      Dashboard
    </button>
  );
}

Note the "use client" directive at the top. useRouter only works in Client Components — it depends on browser APIs (the History API) that don't exist on the server. If you try to call it from a Server Component, you'll get a build or runtime error telling you exactly that.

The docs are direct about when you should reach for this hook at all: use <Link> unless you have a specific reason not to. <Link> gets you prefetching, proper <a> semantics for accessibility and SEO, and middle-click/cmd-click "open in new tab" behavior for free. useRouter is for navigation that's triggered by logic — after a mutation succeeds, after a timeout, inside a keyboard shortcut handler — not for things a user clicks directly.

The Six Methods (Plus One Property)

router.push(href, options)

Navigates to a new route and adds a new entry to the browser's history stack — pressing the back button afterward takes the user to the page they came from.

router.push("/dashboard");
router.push("/dashboard", { scroll: false });

The second argument accepts:

  • scroll (boolean) — by default, Next.js scrolls to the top of the page on navigation. Setting scroll: false disables that, which is useful for things like tab switches within a page where you don't want the viewport to jump.
  • transitionTypes (string array) — passed straight through to React's addTransitionType, letting you tag this specific navigation's transition for styling or animation purposes (e.g. ["slide-forward"] vs ["slide-back"], so a View Transitions CSS rule can react differently depending on direction).

router.replace(href, options)

Identical to push, except it doesn't add a new history entry — it replaces the current one. The classic use case is a login redirect: after a successful login, you replace to the dashboard rather than push, so hitting the back button doesn't take the user back to the login form they just left.

"use client";

import { useRouter } from "next/navigation";

export default function LoginForm() {
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    const ok = await login(formData);
    if (ok) {
      router.replace("/dashboard");
    }
  }

  return <form action={handleSubmit}>{/* ... */}</form>;
}

router.refresh()

This is the one that catches people off guard, because "refresh" sounds like it should reload the page. It doesn't. router.refresh() makes a new request to the server for the current route only, re-runs any Server Components on it, and re-fetches whatever data those Server Components request — but it merges the result into the existing page without a full reload. Client-side state like useState values, scroll position, and any Client Component internal state are preserved.

"use client";

import { useRouter } from "next/navigation";

export function DeleteButton({ id }: { id: string }) {
  const router = useRouter();

  async function handleDelete() {
    await fetch(`/api/items/${id}`, { method: "DELETE" });
    router.refresh(); // re-fetch the list from the server
  }

  return <button onClick={handleDelete}>Delete</button>;
}

Here's the part that actually matters and that the source docs bury in a "Good to know" callout: refresh() clears the client-side cache for the current route, but it does not invalidate anything on the server. If the Server Component on that route reads from a fetch call, an unstable_cache, or a "use cache"-wrapped function that's still within its cache lifetime, refresh() will re-run the component but get back the same cached data — nothing visibly changes, and you'll spend twenty minutes convinced refresh() is broken.

If you need the underlying data to actually be different, you need revalidatePath() or revalidateTag() on the server side (typically inside the Server Action or Route Handler that performed the mutation) — refresh() is for telling the client "go get the current server output again," not "invalidate the cache."

router.prefetch(href, options)

Manually triggers a prefetch for a route, the same mechanism <Link> uses automatically when it scrolls into view. You'd reach for this when you're rendering navigation targets that aren't <Link> components — a custom dropdown menu, for instance, where you want to prefetch on hover rather than on mount.

<div
  onMouseEnter={() => router.prefetch("/settings")}
  onClick={() => router.push("/settings")}
>
  Settings
</div>

As of Next.js 15.4, it accepts an onInvalidate callback, called at most once per prefetch, when the previously prefetched data goes stale. It's a signal to trigger a fresh prefetch rather than navigate with outdated cached data:

router.prefetch("/settings", {
  onInvalidate: () => {
    router.prefetch("/settings"); // re-prefetch with fresh data
  },
});

router.back() and router.forward()

Thin wrappers around the browser's native history navigation — functionally identical to the user pressing their browser's back/forward buttons. There's no options object for either; they simply move one step in whichever direction.

<button onClick={() => router.back()}>Cancel</button>

A common pattern is using router.back() to close a modal that was opened via an intercepting route — closing it should feel like "undoing" the navigation that opened it, not pushing a brand-new route.

router.bfcacheId

This is the newest and least understood piece of the API, added alongside the cacheComponents model. It's an opaque string that identifies the current route segment's "instance" — not the route path itself, but this specific rendering of it.

The rule: it changes whenever a push/replace navigation creates the segment fresh. It stays the same across back/forward navigations, router.refresh() calls, and navigations that only change the search params or hash.

Why would you want that? Because under cacheComponents, Next.js preserves Client Component state across navigations using React's <Activity> component — so a form you half-filled out on /products/1 might still have its half-filled values if you navigate away and use the back button to return. That's usually what you want. But sometimes it's not — if a user pushes forward to /products/2 from /products/1, you generally want a fresh, empty form for the new product, not the leftover draft from the last one.

"use client";

import { useRouter } from "next/navigation";

export default function ProductForm() {
  const { bfcacheId } = useRouter();
  return <form key={bfcacheId}>{/* ... */}</form>;
}

Keying the form on bfcacheId gives you exactly that: a fresh mount (and therefore fresh state) on push/replace navigations, while still preserving state across back/forward, which is what users expect from browser history.

The docs themselves are cautious about this one, and it's worth repeating: prefer resetting state explicitly (in an onSubmit handler, or by deriving a key from your actual data — a draft ID from the server, say) over reaching for bfcacheId. Treat it as a tool for migrating an existing codebase's assumptions about fresh mounts, not a first choice for new code.

Security: Don't Push Untrusted URLs

This is easy to skip past in the docs but genuinely important: never pass an unsanitized, user-controlled string to router.push() or router.replace(). A malicious javascript: URL passed to either of these will execute in the context of your page — this is a real cross-site scripting vector, not a theoretical one. If you're building something like a "redirect to the page you came from" flow using a ?returnTo= query param, validate that it's a same-origin relative path before ever handing it to router.push.

function isSafeInternalPath(path: string): boolean {
  return path.startsWith("/") && !path.startsWith("//");
}

const returnTo = searchParams.get("returnTo");
if (returnTo && isSafeInternalPath(returnTo)) {
  router.push(returnTo);
}

Migrating From next/router (Pages Router)

If you're bringing over code from the Pages Router, three things changed:

Pages Router (next/router)App Router (next/navigation)
router.pathnameusePathname()
router.queryuseSearchParams()
router.events (.on('routeChangeComplete', ...))Composed manually from usePathname + useSearchParams inside a useEffect

There's no direct router.events replacement — instead, you track navigation by watching usePathname() and useSearchParams() change:

"use client";

import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";

export function NavigationEvents() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    const url = `${pathname}?${searchParams}`;
    console.log(url);
    // fires whenever the pathname or search params change
  }, [pathname, searchParams]);

  return null;
}

Wrap this in a <Suspense> boundary wherever you use it — useSearchParams() forces client-side rendering up to its nearest Suspense boundary during prerendering, so an unwrapped NavigationEvents component can quietly opt a much larger part of your tree out of static rendering than you intended.

import { Suspense } from "react";
import { NavigationEvents } from "./components/navigation-events";

export default function Layout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Suspense fallback={null}>
          <NavigationEvents />
        </Suspense>
      </body>
    </html>
  );
}

Common Mistakes

Calling router.refresh() and expecting data to change. As covered above, this only works if the underlying server-side cache has actually expired or been revalidated. Pair it with revalidatePath/revalidateTag in the mutation itself, and let refresh() handle syncing the client to the now-fresh server output.

Using useRouter for links a user clicks directly. If it's a plain "go to this page" click target, use <Link>. You lose automatic prefetching, correct anchor semantics, and native middle-click behavior every time you replace a <Link> with an onClick={() => router.push(...)} handler on a <div> or <button>.

Forgetting the "use client" directive. useRouter is a client-only hook. If the component using it isn't already a Client Component, add the directive at the top of the file (or extract the router-using logic into a small Client Component that a Server Component can render).

Assuming router.push is synchronous in effect. The navigation is scheduled, not instantaneous — if you need code to run only after the new route has actually rendered, that's a different problem (often solved with useEffect on the destination page, not by adding logic immediately after the push call).

Not sanitizing dynamic redirect targets. Covered above, but worth repeating as its own bullet: any URL built from user input needs a same-origin check before it reaches router.push or router.replace.

Key Takeaways

MethodWhat it doesWhat it doesn't do
push(href, opts)Navigate, add history entry
replace(href, opts)Navigate, replace history entry
refresh()Re-render current route from server, clear client cacheInvalidate server-side cache
prefetch(href, opts)Manually prefetch a routeNavigate
back() / forward()Move through browser historyAccept any options
bfcacheIdIdentify the current segment "instance" for keyingReplace explicit state-reset logic in most cases

useRouter is simple on the surface and has real depth once you get past push/replace. The two things worth internalizing before you ship anything with it: refresh() is a client-cache operation, not a server-invalidation one, and any dynamic URL you hand to push/replace needs to be trusted or validated first. Everything else is straightforward once you know it's there.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper