Next.js useRouter vs redirect vs permanentRedirect in App Router: Choosing the Right Navigation Primitive
Master Next.js App Router navigation by understanding when to use useRouter, redirect, and permanentRedirect. Learn the technical differences, performance implications, and real-world patterns that prevent runtime crashes and SEO penalties.
Most Next.js navigation bugs stem from misunderstanding the execution context boundary between client and server. Teams reach for useRouter in Server Components, call redirect() after await points, or use temporary redirects where permanent ones belong. The result is runtime crashes, broken user flows, and invisible SEO penalties that compound over months.
Next.js App Router provides three distinct navigation primitives: useRouter for client-side programmatic navigation, redirect() for server-side temporary redirects, and permanentRedirect() for SEO-friendly 308 redirects. Each operates in a different execution context with different guarantees. The distinction is critical because using the wrong primitive does not always fail immediately. A useRouter.push() call in a Server Component crashes at runtime. A redirect() after an async boundary silently fails to redirect. A temporary redirect where a permanent one belongs costs search rankings every day it remains live.
flowchart LR
A("Navigation needed") --> B("useRouter in Server Component")
B --> C("Runtime crash")
style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct approach separates navigation by execution context and semantic intent. Client-side imperative navigation uses useRouter. Server-side control flow uses redirect() for temporary redirects and permanentRedirect() for permanent moves. Each primitive enforces its constraints through type boundaries and runtime checks that surface errors early when violated.
flowchart LR
A("Navigation needed") --> B("Context-aware primitive")
B --> C("Client: useRouter")
B --> D("Server temporary: redirect")
B --> E("Server permanent: permanentRedirect")
C --> F("Type-safe execution")
D --> F
E --> F
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
useRouteroperates exclusively in Client Components and provides imperative navigation methods (push,replace,back) for user-initiated flows.redirect()throws a Next.js error that the framework catches to issue a 307 temporary redirect. It MUST execute before any async boundaries in Server Components or Route Handlers.permanentRedirect()issues a 308 status code that signals search engines to update their index permanently, the only correct choice for moved content.- Choosing the wrong primitive causes either runtime crashes (client/server boundary violations) or silent failures (redirect after await) that escape type checking.
- The decision tree is deterministic: client imperative navigation uses
useRouter, server temporary usesredirect(), server permanent usespermanentRedirect().
useRouter: Client-Side Programmatic Navigation
The useRouter hook from next/navigation provides imperative navigation methods that run in the browser. This primitive exists for Client Components that need to navigate in response to user actions or client-side state changes.
flowchart LR
A("User action") --> B("Event handler")
B --> C("useRouter().push()")
C --> D("Client-side route transition")
D --> E("No server round-trip")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
useRouter exposes four methods. push(href) adds a new entry to the browser history stack and navigates to the target route. replace(href) replaces the current history entry without adding a new one. back() navigates to the previous entry in the history stack. refresh() re-fetches the current route from the server without a full page reload.
The failure mode here is subtle but expensive. Developers transitioning from Pages Router muscle memory import useRouter in Server Components. The code type-checks because the hook exists in both next/navigation and next/router. The crash happens at runtime when React attempts to call the hook outside a client execution context.
// app/dashboard/page.tsx
// WRONG: Server Component cannot use hooks
export default function DashboardPage() {
const router = useRouter(); // Runtime crash
if (someCondition) {
router.push("/login");
}
return <div>Dashboard</div>;
}The correct pattern marks the component as a Client Component with the "use client" directive. This signals Next.js to bundle the component for browser execution where hooks operate safely.
// app/dashboard/actions-panel.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function ActionsPanel() {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
async function handleComplete() {
setIsProcessing(true);
const result = await fetch("/api/complete", { method: "POST" });
if (result.ok) {
router.push("/success");
} else {
router.push("/error");
}
}
return (
<button onClick={handleComplete} disabled={isProcessing}>
Complete Action
</button>
);
}This pattern keeps the navigation logic co-located with the user interaction. The route transition happens instantly from the user's perspective because no server round-trip is required. The browser fetches the new route's React Server Component payload asynchronously and streams it into the UI.
The replace() method exists for flows where you want to prevent back-button navigation to the intermediate step. Form submissions and authentication redirects commonly use this pattern.
"use client";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
async function handleLogin(formData: FormData) {
const response = await fetch("/api/auth/login", {
method: "POST",
body: formData,
});
if (response.ok) {
// User should not navigate back to login form
router.replace("/dashboard");
}
}
return <form action={handleLogin}>...</form>;
}useRouter does not work with Server Components because hooks require client-side React state. The execution model guarantees that when you call router.push(), the browser handles the transition without server involvement. This matters because server redirects require throwing errors that Next.js catches. Client-side navigation just updates the URL and fetches new content.
redirect: Server-Side Temporary Redirects
The redirect() function from next/navigation triggers a server-side redirect by throwing a special Next.js error. The framework catches this error, aborts the current render, and responds with a 307 Temporary Redirect status code.
flowchart TD
A("Server Component renders") --> B("Condition check")
B --> C("redirect() called")
C --> D("Throws Next.js error")
D --> E("Framework catches error")
E --> F("Responds with 307")
F --> G("Browser navigates")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This primitive exists for conditional server-side navigation based on authentication state, feature flags, or data lookups that happen during render. The 307 status code tells the browser and search engines that this redirect is temporary. Search engines maintain the original URL in their index.
The critical constraint is timing. redirect() throws an error to trigger the redirect. This works reliably when called synchronously during component render. It fails silently when called after an await boundary because the component has already started streaming to the client.
// app/dashboard/page.tsx
// WRONG: redirect after await fails silently
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect("/login"); // TOO LATE: render already streaming
}
return <div>Dashboard content</div>;
}The failure happens because Server Components stream their output. Once the response starts, Next.js cannot change the status code. The redirect() call throws, but the framework has already committed to a 200 response. The user sees a broken page or an error boundary.
The correct pattern hoists authentication checks before any async operations. This guarantees redirect() executes while the response is still uncommitted.
// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export default async function DashboardPage() {
const session = await getSession();
// Check synchronously before rendering
if (!session) {
redirect("/login");
}
// Session confirmed, safe to proceed
const data = await fetchDashboardData(session.userId);
return <DashboardView data={data} />;
}Route Handlers use redirect() the same way. The function throws an error that Next.js catches and converts to a redirect response.
// app/api/checkout/route.ts
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.cartId) {
redirect("/cart"); // Client sees 307 redirect
}
const order = await createOrder(body.cartId);
return Response.json({ orderId: order.id });
}The distinction between 307 and 308 status codes matters for SEO. A 307 Temporary Redirect tells search engines to keep checking the original URL. A 308 Permanent Redirect tells them to update their index. Using redirect() for moved content means search engines waste crawl budget checking the old URL indefinitely.
permanentRedirect: SEO-Friendly 308 Redirects
The permanentRedirect() function from next/navigation works identically to redirect() with one critical difference: it issues a 308 Permanent Redirect instead of 307. This status code signals to search engines that the content has moved permanently and they should update their index.
flowchart TD
A("Server Component renders") --> B("Permanent move detected")
B --> C("permanentRedirect() called")
C --> D("Throws Next.js error")
D --> E("Framework catches error")
E --> F("Responds with 308")
F --> G("Search engines update index")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The semantic difference is intent. Use redirect() when the redirect is conditional or temporary (authentication gates, feature flags, A/B tests). Use permanentRedirect() when content has moved to a new URL and the old URL should stop appearing in search results.
The timing constraints match redirect() exactly. The call must happen before any await boundaries or the framework cannot change the response status code.
// app/blog/old-post-slug/page.tsx
import { permanentRedirect } from "next/navigation";
export default function OldPostPage() {
// Content moved to new URL structure
permanentRedirect("/blog/new-post-slug");
}This pattern is common after URL structure refactors. Old URLs return 308 redirects that preserve SEO equity while pointing users and search engines to the new location. The browser follows the redirect automatically. Search engines update their index on the next crawl.
Dynamic routes combine this pattern with data lookups to redirect old slugs to new ones.
// app/blog/[slug]/page.tsx
import { permanentRedirect } from "next/navigation";
import { getPostBySlug, getPostRedirect } from "@/lib/blog";
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPostBySlug(params.slug);
if (!post) {
const redirectTarget = await getPostRedirect(params.slug);
if (redirectTarget) {
permanentRedirect(`/blog/${redirectTarget}`);
}
notFound();
}
return <article>{post.content}</article>;
}The lookup checks a redirect mapping table before returning 404. If the old slug maps to a new one, permanentRedirect() sends the user to the correct location. If no mapping exists, the route returns 404. This pattern preserves inbound links while signaling to search engines that the old URL is obsolete.
Route Handlers use permanentRedirect() for API endpoint moves.
// app/api/v1/users/route.ts
import { permanentRedirect } from "next/navigation";
export async function GET() {
// API moved to new version
permanentRedirect("/api/v2/users");
}The failure mode here is using redirect() where permanentRedirect() belongs. The code works but search engines keep the old URL in their index. Over time this costs crawl budget and dilutes page authority. The fix is replacing redirect() with permanentRedirect() for moved content.
Comparing All Three: When to Use Each
The decision tree for choosing a navigation primitive follows execution context and semantic intent. These factors determine which primitive enforces the correct constraints.
flowchart LR
A("Navigation needed") --> B("Client Component?")
B -->|Yes| C("useRouter")
B -->|No| D("Server Component/Route Handler")
D --> E("Permanent move?")
E -->|Yes| F("permanentRedirect")
E -->|No| G("redirect")
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
useRouter is the only choice for Client Components. It provides imperative navigation methods that respond to user actions or client state changes. The browser handles the route transition without server involvement.
redirect() is the default for server-side conditional navigation. Authentication checks, feature flags, and temporary routing logic use this primitive. The 307 status code tells clients the redirect is temporary.
permanentRedirect() is the only correct choice for moved content. URL structure refactors, deprecated endpoints, and consolidated pages require this primitive. The 308 status code tells search engines to update their index.
The execution context boundary is non-negotiable. Client Components cannot use redirect() or permanentRedirect() because these functions throw errors that only Next.js Server Component infrastructure catches. Server Components cannot use useRouter because hooks require client-side React state.
The timing constraint applies to both server redirect primitives. They must execute before any await boundaries. This guarantees the framework can set the redirect status code before committing the response. Violating this constraint produces silent failures where the user sees an error instead of a redirect.
The semantic distinction between temporary and permanent redirects compounds over time. A temporary redirect where a permanent one belongs costs search rankings every day. Search engines waste crawl budget checking the old URL. The page authority never transfers to the new URL. Fixing this requires changing redirect() to permanentRedirect() and waiting for search engines to re-crawl.
Real-World Code Examples and Patterns
Authentication flows demonstrate the context boundary between client and server navigation. The pattern splits the responsibility: Server Components check authentication and redirect unauthenticated users. Client Components handle post-authentication navigation.
// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import DashboardClient from "./dashboard-client";
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect("/login");
}
return <DashboardClient userId={session.userId} />;
}// app/dashboard/dashboard-client.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function DashboardClient({ userId }: { userId: string }) {
const router = useRouter();
const [activeTab, setActiveTab] = useState("overview");
function handleTabChange(tab: string) {
setActiveTab(tab);
router.push(`/dashboard?tab=${tab}`, { scroll: false });
}
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.replace("/");
}
return (
<div>
<nav>
<button onClick={() => handleTabChange("overview")}>Overview</button>
<button onClick={() => handleTabChange("settings")}>Settings</button>
<button onClick={handleLogout}>Logout</button>
</nav>
<TabContent tab={activeTab} userId={userId} />
</div>
);
}The Server Component enforces authentication before rendering. This check happens early enough that redirect() works reliably. The Client Component handles in-dashboard navigation and logout flows. These require client-side state management and imperative navigation.
URL structure migrations use permanentRedirect() with dynamic lookups. This pattern supports gradual migrations where old and new URLs coexist temporarily.
// app/articles/[slug]/page.tsx
import { permanentRedirect } from "next/navigation";
import { getArticle, getSlugRedirect } from "@/lib/articles";
export default async function ArticlePage({
params,
}: {
params: { slug: string };
}) {
// Check if this slug exists in new structure
let article = await getArticle(params.slug);
if (!article) {
// Check if old slug maps to new slug
const newSlug = await getSlugRedirect(params.slug);
if (newSlug) {
permanentRedirect(`/articles/${newSlug}`);
}
// No article and no redirect: 404
notFound();
}
return (
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
);
}The redirect mapping lives in a database table or static JSON file. The lookup adds latency but preserves SEO equity. Search engines update their index after encountering the 308 response.
Multi-step forms combine both patterns. Server Actions handle form submission and server-side validation. Client Components manage step transitions and optimistic UI updates.
// app/onboarding/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { submitOnboarding } from "./actions";
export default function OnboardingPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({});
async function handleComplete() {
const result = await submitOnboarding(formData);
if (result.success) {
// Prevent back navigation to form
router.replace("/dashboard");
} else {
// Show error, stay on form
setError(result.error);
}
}
return (
<form>
{step === 1 && <StepOne onNext={() => setStep(2)} />}
{step === 2 && <StepTwo onNext={() => setStep(3)} />}
{step === 3 && <StepThree onComplete={handleComplete} />}
</form>
);
}// app/onboarding/actions.ts
"use server";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export async function submitOnboarding(data: unknown) {
const session = await getSession();
if (!session) {
redirect("/login");
}
// Server-side validation and storage
await saveOnboardingData(session.userId, data);
return { success: true };
}The Server Action enforces authentication. If the session expired during form completion, redirect() sends the user back to login. The Client Component handles step transitions and calls router.replace() after successful submission to prevent back-button navigation to the completed form.
Common Mistakes and How to Avoid Them
The most expensive mistake is using temporary redirects for permanent moves. This happens when developers reach for redirect() without considering the semantic difference from permanentRedirect(). The code works but search engines never update their index.
flowchart LR
A("Content moved") --> B("redirect() used")
B --> C("307 Temporary sent")
C --> D("Search engines keep checking old URL")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The fix is auditing existing redirects and changing moved content to use permanentRedirect(). The impact compounds over time as search engines waste crawl budget and dilute page authority.
flowchart LR
A("Content moved") --> B("permanentRedirect() used")
B --> C("308 Permanent sent")
C --> D("Search engines update index")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The second common failure is calling redirect() after async boundaries. This produces silent failures where users see error boundaries instead of redirects.
// WRONG: redirect after await
export default async function Page() {
const data = await fetchData();
if (data.requiresUpgrade) {
redirect("/upgrade"); // Silent failure
}
return <Content data={data} />;
}The component starts streaming the response during fetchData(). When redirect() throws, Next.js has already committed to a 200 status code. The error propagates to the nearest error boundary. Users see a broken page instead of a redirect.
The fix hoists the conditional check before any await points. This requires restructuring async operations to separate authentication/authorization from data fetching.
// CORRECT: check before await
export default async function Page() {
const session = await getSession();
if (session.needsUpgrade) {
redirect("/upgrade");
}
const data = await fetchData(session);
return <Content data={data} />;
}The third mistake is importing useRouter from the wrong package. Next.js exposes two useRouter hooks: one in next/navigation for App Router and one in next/router for Pages Router. The APIs differ completely.
// WRONG: Pages Router hook in App Router
import { useRouter } from "next/router";
export default function ClientComponent() {
const router = useRouter(); // Type errors or runtime crashes
router.push("/somewhere");
}The correct import uses next/navigation in App Router code. TypeScript catches this mistake when types are configured correctly, but mixed codebases during migration often have both imports present.
// CORRECT: App Router hook
import { useRouter } from "next/navigation";
export default function ClientComponent() {
const router = useRouter();
router.push("/somewhere");
}The fourth mistake is using router.push() when router.replace() belongs. Form submissions and authentication flows should not allow back-button navigation to the previous step.
// WRONG: push allows back navigation
async function handleLogin(formData: FormData) {
await submitLogin(formData);
router.push("/dashboard"); // User can navigate back to login form
}
// CORRECT: replace prevents back navigation
async function handleLogin(formData: FormData) {
await submitLogin(formData);
router.replace("/dashboard"); // Back button skips login form
}The distinction matters for user experience. After logging in, the back button should return to the page before the login screen, not the login screen itself. The same pattern applies to checkout flows and multi-step forms.
Frequently Asked Questions
Can I use redirect() in Client Components?
No. redirect() and permanentRedirect() throw errors that only Next.js Server Component infrastructure catches. Client Components must use useRouter for programmatic navigation. Attempting to call redirect() in a Client Component produces a runtime error that crashes the component.
Why does redirect() after await fail silently?
Server Components stream their response. Once streaming starts, Next.js cannot change the HTTP status code. Calling redirect() after an await boundary throws an error, but the framework has already sent a 200 status. The error propagates to the nearest error boundary instead of triggering a redirect.
When should I use router.replace() instead of router.push()?
Use replace() when you want to prevent back-button navigation to the current page. Login redirects, form submissions, and checkout completions should use replace() so the back button skips the intermediate step. Use push() for standard navigation where back-button history makes sense.
How do search engines handle 307 vs 308 redirects?
A 307 Temporary Redirect tells search engines to keep the original URL in their index and check it periodically. A 308 Permanent Redirect tells them to replace the old URL with the new one. Using 307 for moved content wastes crawl budget and prevents page authority from transferring to the new URL.
Can I redirect to external URLs with these primitives?
Yes. All three primitives accept absolute URLs. useRouter().push("https://example.com") works in Client Components. redirect("https://example.com") and permanentRedirect("https://example.com") work in Server Components. The browser follows the redirect normally.
Choosing the Right Navigation Primitive for Your Use Case
The three navigation primitives in Next.js App Router enforce clear boundaries that prevent entire classes of bugs. useRouter works exclusively in Client Components for imperative navigation. redirect() and permanentRedirect() work exclusively in Server Components for conditional routing. The choice between temporary and permanent redirects determines SEO outcomes that compound over months.
The decision tree is deterministic. Client-side navigation uses useRouter. Server-side temporary redirects use redirect(). Server-side permanent redirects use permanentRedirect(). Violating these boundaries produces runtime crashes or silent failures that escape type checking.
The timing constraint on server redirects is non-negotiable. Both redirect() and permanentRedirect() must execute before any await boundaries. This guarantees the framework can set the redirect status code before committing the response. Violating this constraint produces error boundaries instead of redirects.
That covers the essential patterns for Next.js App Router navigation. Apply these in production and the difference will be immediate. Authentication flows stop crashing. Search engines update their index correctly. Users navigate through multi-step forms without broken back-button behavior. The type system catches Client/Server boundary violations at build time instead of runtime. Master these three primitives and navigation bugs become the exception rather than the norm.