Type something to search...
Programmatic Navigation in Next.js with useRouter, redirect, and permanentRedirect

Programmatic Navigation in Next.js with useRouter, redirect, and permanentRedirect

Most navigation in a Next.js app happens through links: the user clicks, the URL changes, a new page renders. But plenty of navigation isn't triggered by a link. A form saves and should send the user to the new record. A logged-out visitor hits a protected page. A product's URL changes and old links need to point to the new one. A filter dropdown should update the query string.

The App Router gives you three main tools for this, and they run in different places: useRouter in Client Components, and redirect and permanentRedirect on the server (and during client rendering). Choosing the wrong one usually doesn't break anything outright, but it leads to subtle bugs like redirects that never fire, the wrong HTTP status code, or a back button that loops.

This post explains what each API does, where it runs, which status codes it produces, and the patterns I reach for in real apps.

Quick Reference

APIWhere it runsWhat it doesHTTP status
useRouter()Client Components, usually in event handlersClient-side navigationNone (no request redirect)
redirect()Server Components, Server Functions, Route Handlers, Client Component renderTemporary redirect307, or 303 for Server Action form posts
permanentRedirect()Same as redirect()Permanent redirect308, or 303 for Server Action form posts
redirects in next.configBefore rendering, based on pathKnown, static redirects307 or 308
NextResponse.redirectproxy.ts, before renderingConditional redirectsAny

The rule of thumb: if a user action in the browser triggers the navigation, use useRouter. If the server decides where the user should be, use redirect or permanentRedirect. The last two rows are for redirects that happen before any of your components run, which I'll touch on at the end.

And if all you need is a clickable element that goes somewhere, use the Link component instead of any of these. It prefetches and works without JavaScript. I cover it in depth in the Next.js Link component guide.

useRouter: Navigating from the Client

useRouter is a hook, so it only works in Client Components. Import it from next/navigation, not next/router (that's the Pages Router version and won't work in app/).

// app/ui/logout-button.tsx
"use client";

import { useRouter } from "next/navigation";

export function LogoutButton() {
  const router = useRouter();

  async function handleLogout() {
    await fetch("/api/logout", { method: "POST" });
    router.replace("/login");
    router.refresh();
  }

  return (
    <button type="button" onClick={handleLogout}>
      Log out
    </button>
  );
}

After the logout request finishes, router.replace("/login") navigates without adding a history entry, so pressing Back won't return to a page the user can no longer see. router.refresh() then tells Next.js to re-render Server Components so anything that reads the session (like a header avatar) updates.

The Router Methods

  • router.push(href, options) navigates and adds a history entry. Back returns to the previous page.
  • router.replace(href, options) navigates and replaces the current entry. Use it for redirects, logins, and wizard steps you don't want users to step "back" into.
  • router.refresh() re-requests the current route from the server and re-renders Server Components. Client state like useState values and scroll position is preserved. It clears the client-side cache for the route but doesn't invalidate server caches; use revalidatePath or revalidateTag for that.
  • router.prefetch(href) loads a route in advance, useful when you know the user is about to go somewhere that isn't a visible link.
  • router.back() and router.forward() move through browser history.

Both push and replace accept an options object. scroll: false stops Next.js from scrolling to the top after navigation, and transitionTypes passes types to React's view transition system if you're using it.

Updating Search Params

A common use for useRouter is syncing UI state like filters, sorting, or pagination into the URL. Read the current params with useSearchParams, build a new URLSearchParams, and push it:

// app/products/sort-select.tsx
"use client";

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

export function SortSelect() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  function handleChange(event: React.ChangeEvent<HTMLSelectElement>) {
    const params = new URLSearchParams(searchParams.toString());
    params.set("sort", event.target.value);
    params.delete("page"); // reset pagination when sorting changes

    router.replace(`${pathname}?${params.toString()}`, { scroll: false });
  }

  return (
    <select
      defaultValue={searchParams.get("sort") ?? "newest"}
      onChange={handleChange}
    >
      <option value="newest">Newest</option>
      <option value="price-asc">Price: low to high</option>
      <option value="price-desc">Price: high to low</option>
    </select>
  );
}

I use replace here because each sort change isn't a meaningful step the user wants to go back through. scroll: false keeps them where they are on the page. The page component reads searchParams (a promise in Next.js 16) and renders the sorted list on the server.

Because useSearchParams makes a component render on the client up to the nearest Suspense boundary during prerendering, wrap it when you use it on a statically rendered page:

// app/products/page.tsx
import { Suspense } from "react";
import { SortSelect } from "./sort-select";
import { ProductList } from "./product-list";

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<{ sort?: string }>;
}) {
  const { sort = "newest" } = await searchParams;

  return (
    <main>
      <Suspense fallback={<div className="h-10" />}>
        <SortSelect />
      </Suspense>
      <ProductList sort={sort} />
    </main>
  );
}

Showing Pending State

Navigations in the App Router run inside React transitions. If you wrap your push call in startTransition, you get an isPending flag that stays true until the new route has rendered:

// app/ui/next-step-button.tsx
"use client";

import { useTransition } from "react";
import { useRouter } from "next/navigation";

export function NextStepButton({ href }: { href: string }) {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  return (
    <button
      type="button"
      disabled={isPending}
      onClick={() => startTransition(() => router.push(href))}
    >
      {isPending ? "Loading..." : "Continue"}
    </button>
  );
}

That's a simple way to disable a button and avoid double clicks while a slow page loads.

Don't Push Untrusted URLs

router.push and router.replace will happily execute a javascript: URL. If you navigate based on input you don't control, like a ?next= query parameter after login, validate it first:

// lib/safe-redirect.ts
export function safeInternalPath(value: string | null, fallback = "/") {
  if (!value) return fallback;
  // Only allow same-site absolute paths like "/dashboard", not "//evil.com"
  if (!value.startsWith("/") || value.startsWith("//")) return fallback;
  return value;
}

Use this helper for both client-side navigation and server redirects. Open redirects are a common phishing vector.

redirect: Server-Decided Navigation

redirect() from next/navigation sends the user somewhere else. It throws a special NEXT_REDIRECT error, which stops rendering immediately, so code after it doesn't run and you don't need to return it. Its return type is never, which helps TypeScript narrow types after the call.

In Server Components

The classic case is an auth check:

// app/account/page.tsx
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";

export default async function AccountPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect("/login?next=/account");
  }

  return <h1>Welcome back, {user.name}</h1>;
}

On a normal request, this produces a 307 Temporary Redirect. If the response has already started streaming (for example, the redirect is inside a component under a Suspense boundary), Next.js can no longer change the status code, so it injects a meta tag that performs the redirect on the client instead. Either way the user ends up in the right place, but if you care about the HTTP response itself, run the check early, before any Suspense boundaries.

For auth specifically, checking in a page is fine as a guard, but it's not a security boundary on its own. Any data access should check permissions too. For a fuller picture, see managing authentication in a Next.js application.

In Server Actions

The most common use of redirect is after a mutation. Save the record, revalidate what changed, then send the user to the result:

// app/posts/actions.ts
"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

export async function createPost(formData: FormData) {
  const title = String(formData.get("title") ?? "").trim();
  if (!title) {
    return { error: "Title is required" };
  }

  let postId: string;
  try {
    const post = await db.post.create({ data: { title } });
    postId = post.id;
  } catch {
    return { error: "Could not save the post. Please try again." };
  }

  revalidatePath("/posts");
  redirect(`/posts/${postId}`);
}

Notice that redirect sits outside the try block. Because it works by throwing, a catch around it would intercept the redirect error and treat it like a database failure. This is the single most common redirect bug I see.

In a Server Action, redirect behaves differently depending on how the form was submitted:

  • With JavaScript loaded, it performs a client-side navigation. No full page reload.
  • Without JavaScript (a progressively enhanced form post), it responds with a 303 See Other, so the browser follows up with a GET.

It also defaults to push in Server Actions, adding a history entry, whereas everywhere else it defaults to replace. You can override that with the second argument:

import { redirect, RedirectType } from "next/navigation";

redirect("/checkout/confirmation", RedirectType.replace);

Replacing makes sense after a checkout: you don't want Back to land on a form that would submit the order again. The type argument has no effect in Server Components.

In Route Handlers

redirect also works in Route Handlers, which is handy for things like OAuth callbacks:

// app/auth/callback/route.ts
import { redirect } from "next/navigation";
import { exchangeCodeForSession } from "@/lib/auth";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");

  if (!code) {
    redirect("/login?error=missing_code");
  }

  await exchangeCodeForSession(code);
  redirect("/dashboard");
}

In Client Components (During Render)

You can call redirect while a Client Component renders, but not inside an event handler. During the initial server render it performs a server-side redirect; during client rendering it navigates.

// app/ui/require-onboarding.tsx
"use client";

import { redirect } from "next/navigation";

export function RequireOnboarding({ done }: { done: boolean }) {
  if (!done) {
    redirect("/onboarding");
  }
  return null;
}

If you need to navigate in response to a click, use useRouter, or have the click submit a form to a Server Action that calls redirect.

permanentRedirect: When the URL Has Moved

permanentRedirect() works exactly like redirect() but returns a 308 Permanent Redirect. Browsers and search engines treat that as "this URL has moved for good": browsers may cache it, and search engines transfer ranking signals to the new URL.

Use it when a resource's canonical URL changes. A classic example is slugs that include a title:

// app/articles/[id]/[slug]/page.tsx
import { notFound, permanentRedirect } from "next/navigation";
import { getArticle } from "@/lib/articles";

export default async function ArticlePage({
  params,
}: {
  params: Promise<{ id: string; slug: string }>;
}) {
  const { id, slug } = await params;
  const article = await getArticle(id);

  if (!article) notFound();

  if (article.slug !== slug) {
    permanentRedirect(`/articles/${id}/${article.slug}`);
  }

  return <h1>{article.title}</h1>;
}

If someone renames an article, old links with the outdated slug still work. They get a 308 to the current URL, and search engines update their index.

Another good fit is a Server Action that changes a canonical URL, like a username:

// app/settings/actions.ts
"use server";

import { permanentRedirect } from "next/navigation";
import { revalidateTag } from "next/cache";
import { updateUsernameInDb } from "@/lib/users";

export async function updateUsername(formData: FormData) {
  const username = String(formData.get("username") ?? "");
  await updateUsernameInDb(username);

  revalidateTag("profile", "max");
  permanentRedirect(`/u/${username}`);
}

In Next.js 16, revalidateTag takes a second argument, a cache life profile such as "max", which marks the tagged data as stale and refreshes it in the background on the next visit.

Be Careful with "Permanent"

Browsers can cache a 308 aggressively. If you use it for something that's actually temporary, like an auth redirect or a maintenance page, users may keep getting redirected after you've removed it. When in doubt, use redirect. Reserve permanentRedirect for URLs that have truly moved.

Redirects Before Rendering

Sometimes the redirect doesn't need any of your component code to run:

  • Known URL changes, like a site restructure, belong in the redirects array in next.config.ts. They're matched by path pattern and set permanent: true (308) or false (307).
  • Conditional redirects based on cookies, headers, or geography belong in proxy.ts (formerly middleware.ts in earlier versions), using NextResponse.redirect.
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async redirects() {
    return [
      {
        source: "/blog/:slug",
        destination: "/articles/:slug",
        permanent: true,
      },
    ];
  },
};

export default nextConfig;

Config redirects run before Proxy, and both run before any page renders, so they're the cheapest option. Use redirect and permanentRedirect when the decision depends on data your page or action loads.

Common Mistakes

Calling redirect inside try. The catch swallows it. Move the call after the try/catch, or call unstable_rethrow(error) at the top of the catch block.

Using redirect in an onClick. It's not supported in event handlers. Use router.push or submit to a Server Action.

Forgetting router.refresh() after a client mutation. If you change data with a fetch call from the client and then navigate, Server Components may show stale data. Either use a Server Action with revalidatePath, or call router.refresh().

Using push for redirects. After login or logout, replace keeps the Back button from returning to a page that will just redirect again.

Importing from next/router. In the App Router, everything lives in next/navigation.

Conclusion

Navigation that the user initiates in the browser belongs to useRouter: push for normal steps, replace when the old page shouldn't be revisited, and refresh when server data changed. Navigation the server decides belongs to redirect, which gives you a 307 (or a 303 for form posts) and works in components, actions, and Route Handlers. When a URL has moved for good, permanentRedirect returns a 308 and tells search engines to follow.

Keep both server functions outside try blocks, validate any URL you didn't create, and push static redirects down into next.config.ts or proxy.ts when no component logic is needed.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading