
Next.js redirect
Every non-trivial Next.js app eventually needs to send a visitor somewhere other than the page they asked for — bounce an unauthenticated user to /login, send someone straight to the canonical URL of a resource they just created, or close off a route that no longer exists. Next.js gives you several tools for this, and redirect() is the one you reach for from inside your rendering and mutation code: Server Components, Route Handlers, and Server Functions. It looks like a one-line utility, but its behavior — the exact status code it sends, how it interacts with try/catch, what happens when JavaScript is disabled, and where you're allowed to call it from — is more nuanced than the signature suggests, and getting it wrong is one of the more common sources of confusing bugs in App Router apps.
This article is the dedicated reference for redirect() itself. If you want a broader survey of every way Next.js can redirect a user — next.config.js redirects, Proxy-based rewrites, useRouter().push(), and this function — that's covered by the "Handling redirects" guide elsewhere on this blog. Here, we're going deep on this one function: its full signature, its status-code behavior, where it can and can't be called, and the mistakes that trip people up in production.
What redirect() Actually Does
redirect() is imported from next/navigation, and it does something slightly unusual for a function: it doesn't return control to your code. Calling it throws a special internal error (NEXT_REDIRECT) that Next.js's rendering pipeline intercepts, and that interception is what actually performs the redirect. Any code you write after a redirect() call in the same execution path is dead code — it will never run, because the throw unwinds the stack before it gets there.
// app/team/[id]/page.tsx
import { redirect } from "next/navigation";
async function fetchTeam(id: string) {
const res = await fetch(`https://api.example.com/teams/${id}`);
if (!res.ok) return undefined;
return res.json();
}
export default async function Profile({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const team = await fetchTeam(id);
if (!team) {
redirect("/login");
}
// Everything below only runs if redirect() was NOT called.
return <TeamDashboard team={team} />;
}
Because the function's return type is TypeScript's never, you don't need to write return redirect(...) — TypeScript already understands that nothing after the call executes, and your linter won't complain about an unreachable return <TeamDashboard> that never fires. This is a small but genuinely nice bit of type-system honesty: the signature tells you exactly how the function behaves, rather than pretending it's an ordinary function that happens to redirect as a side effect.
The Two Parameters
redirect(path, type);
| Parameter | Type | Description |
|---|---|---|
path | string | The destination — a relative path (/login) or an absolute URL (https://example.com) |
type | 'replace' (default) or 'push' (Server Action default) | Which browser-history operation the client-side navigation performs |
The type parameter is the part people miss most often, because its default value quietly changes depending on where you call redirect() from. Outside of a Server Action — in a Server Component, for example — the default is replace: the current entry in the browser's history stack is swapped out, so pressing the back button doesn't take the user back to the page that redirected them. Inside a Server Action, the default flips to push: a new history entry is added, so the back button does return to the pre-action state.
That asymmetry actually makes sense once you think about the two situations it's designed for. A Server Component redirect is usually a gate — "you're not allowed to see this page, go here instead" — and you don't want the back button to loop the user right back into the same gate. A Server Action redirect is usually the result of a user-initiated action — "you submitted the form, here's the result" — and preserving the ability to go back to the form (to edit and resubmit, say) is often the more useful behavior.
You can override either default explicitly using the RedirectType enum-like object:
import { redirect, RedirectType } from "next/navigation";
// Force a "push" navigation from a Server Component
redirect("/onboarding/step-2", RedirectType.push);
// Force a "replace" navigation from a Server Action
redirect("/dashboard", RedirectType.replace);
One subtlety worth internalizing: the type parameter only affects the client-side navigation behavior when JavaScript is available and the app is doing a soft, in-place transition. It has no effect at all when redirect() is called from a plain Server Component during the initial render — there, the browser is simply told via an HTTP-level or meta-tag redirect to go to a new URL, and the browser's own history semantics apply. Where type actually matters is Client Components and the client-side navigation Server Actions perform.
The Status Code Story: Why 307, Not 302
If you've worked with HTTP redirects outside of Next.js, you were probably taught that 302 means "temporary redirect" and 301 means "permanent redirect." Next.js deliberately doesn't use either of those for redirect(). Instead:
redirect()sends a 307 (Temporary Redirect)permanentRedirect()— the sibling function for permanent moves — sends a 308 (Permanent Redirect)
The reason comes down to a decades-old browser inconsistency around HTTP method preservation. When browsers follow a 302 redirect, most of them silently rewrite the original request method to GET, regardless of what the original method was. That's a problem for a very common pattern: you POST to /users to create a resource, the server wants to redirect you to the canonical URL of the thing you just created, and if that redirect is a 302, the browser turns your intended follow-up request into a GET. You end up making a GET /people request when what actually made sense was a POST /people.
307 and 308 fix this by explicitly preserving the original HTTP method through the redirect. A POST that gets redirected with a 307 or 308 stays a POST on the other end. This is the correct, standards-compliant behavior for a redirect that's the result of an action rather than a simple navigation, and it's why Next.js standardized on these codes instead of the more historically familiar 301/302 pair.
There's one more exception worth knowing: Server Action form submissions specifically use a 303 (See Other) response rather than 307, which — unlike 307 — does tell the browser to follow up with a GET request regardless of the original method. That's intentional: a progressively-enhanced form submission (one that still works with JavaScript disabled) is inherently a POST, and after that POST completes, you generally want the browser to land on the result page via a plain GET, not to resubmit the form data again. When JavaScript is available, though, Server Actions skip the HTTP redirect step entirely and perform a client-side navigation instead — the 303/307 distinction is really a fallback for the no-JS case.
Where You Can — and Can't — Call It
redirect() is valid in:
- Server Components, during rendering
- Route Handlers
- Server Functions (Server Actions)
- Client Components, but only during the render phase
That last one surprises people. You can call redirect() directly inside a Client Component's render logic:
"use client";
import { redirect, usePathname } from "next/navigation";
export function AdminGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
if (pathname.startsWith("/admin") && !pathname.includes("/login")) {
redirect("/admin/login");
}
return <>{children}</>;
}
What you cannot do is call it from inside an event handler — a click handler, a form's onSubmit, a useEffect callback, anything that fires outside of the render pass. For those cases, Next.js gives you a different tool: the useRouter() hook's imperative router.push() or router.replace() methods. The distinction is architectural, not arbitrary: redirect() relies on the same throw-and-unwind mechanism that works during React's render phase, and that mechanism doesn't have a meaningful place to unwind to once you're inside an already-committed event handler running well after render finished.
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleClick() {
await fetch("/api/logout", { method: "POST" });
router.push("/login"); // NOT redirect() — this is an event handler
}
return <button onClick={handleClick}>Log out</button>;
}
If you reach for redirect() inside a handleClick, you'll get a runtime error, because there's no active render pass for the thrown NEXT_REDIRECT signal to be caught by.
The try/catch Gotcha
This is the single most common mistake with redirect(), and it's subtle enough that it slips past code review regularly. Because redirect() works by throwing, wrapping it in a try block means your catch clause intercepts the redirect's internal error before Next.js's framework code ever sees it:
// WRONG — the redirect gets swallowed by the catch block
async function createPost(formData: FormData) {
"use server";
try {
const post = await db.post.create({ data: { ... } });
redirect(`/posts/${post.id}`); // throws NEXT_REDIRECT
} catch (error) {
console.error("Failed to create post", error); // catches the redirect too!
return { error: "Something went wrong" };
}
}
In the code above, creating the post succeeds, redirect() fires and throws, and then your own catch block catches that throw, logs it as if it were a real failure, and swallows it — the user never actually gets redirected, and instead sees whatever your error-handling logic produces. The fix is to keep redirect() strictly outside any try/catch that might intercept it:
// RIGHT — redirect happens outside the try block
async function createPost(formData: FormData) {
"use server";
let postId: string;
try {
const post = await db.post.create({ data: { ... } });
postId = post.id;
} catch (error) {
console.error("Failed to create post", error);
return { error: "Something went wrong" };
}
redirect(`/posts/${postId}`);
}
This applies equally in Route Handlers and Server Actions — anywhere you're doing fallible work followed by a redirect, structure the code so the fallible part is inside the try and the redirect call is not.
Redirecting to External URLs
redirect() isn't limited to paths inside your own application — it accepts full absolute URLs too, which makes it usable for sending users off to a third-party OAuth provider, a payment processor, or any other external destination:
export async function connectStripe() {
"use server";
const url = await createStripeOAuthUrl();
redirect(url); // https://connect.stripe.com/oauth/authorize?...
}
There's no special handling needed here beyond passing the full URL string — Next.js doesn't try to validate that the destination is same-origin.
Redirecting Before Render: When redirect() Isn't the Right Tool
It's worth being explicit about what redirect() is not for. Because it only fires during rendering (or during a Server Function's execution), it's inherently a runtime, per-request decision made after your component has already started doing work — fetching data, checking conditions, and so on. If you want to redirect a class of requests before any rendering work happens at all — based purely on the incoming URL, a cookie, or a header — the better tools are next.config.js redirects for static, build-time-known rules, or Proxy (proxy.js) for dynamic, request-time logic that still runs ahead of your route's render pass. Reaching for redirect() when a config-level redirect or a Proxy rewrite would do the same job with less runtime cost is a common inefficiency: you end up paying for a partial render just to immediately throw it away.
Streaming, Suspense, and How the Redirect Actually Reaches the Browser
When redirect() is called during a normal, non-streamed render, Next.js can send a clean HTTP-level redirect response — the browser never sees the original page's HTML at all. But if the call happens inside a component that's already streaming (for example, deep inside a <Suspense> boundary whose fallback has already been flushed to the client), the HTTP headers have already been sent, so an HTTP-level redirect is no longer possible. In that situation, Next.js instead injects a <meta http-equiv="refresh"> tag into the streamed output, which the browser picks up and acts on as soon as it parses that chunk. Functionally, the end result for the user is the same — they land on the destination page — but it's worth knowing this happens, because it means a redirect issued deep inside a slow, streamed subtree can visibly take a beat longer to fire than one issued at the top of a fast, synchronous render. If you're building a gate that should redirect as early and cleanly as possible (an auth check, for instance), doing that check before any Suspense boundary — rather than deep inside one — keeps you on the cleaner, faster HTTP-redirect path.
redirect() vs. notFound() vs. permanentRedirect()
These three functions get confused with each other because they all "stop the current render and do something else," but they answer different questions:
| Function | Use when... | HTTP behavior |
|---|---|---|
redirect() | The resource exists, but the user should go somewhere else right now (temporarily) | 307 (303 for Server Action form fallback) |
permanentRedirect() | The resource has permanently moved — search engines and caches should update their records | 308 |
notFound() | The resource genuinely doesn't exist at this URL and never will | 404, renders your nearest not-found.js |
A common mistake is using redirect('/login') for something that's really a 404 in disguise — for instance, requesting a team page for a team ID that was deleted. If the "right" answer for that URL is "this doesn't exist," notFound() is the more honest response; using redirect() there just relocates the 404 experience somewhere else instead of describing it accurately to both the user and any crawler indexing your site.
Similarly, defaulting to redirect() for moves that are actually permanent (a page that's been renamed forever, a domain migration) under-serves your SEO: search engines treat a 307 as "check back later, this might revert," and will keep the old URL in their index rather than fully transferring its ranking signal to the new one. If the move is permanent, permanentRedirect() communicates that correctly and lets search engines consolidate their understanding of the URL faster.
Common Mistakes, Collected
- Wrapping
redirect()intry/catch. Covered in detail above — always keep it outside thetryblock. - Calling it from an event handler. Use
useRouter().push()/.replace()instead;redirect()only works during render. - Using it for content that doesn't exist. Reach for
notFound()when the honest answer is "this resource isn't here," not "go somewhere else." - Using it for permanent moves. Use
permanentRedirect()when the destination is the new, canonical, forever home of the content — it matters for SEO. - Assuming the
typeparameter changes server-side behavior. It only affects client-side history-stack behavior; the HTTP-level redirect itself is unaffected bytype. - Putting it deep inside a Suspense boundary when it doesn't need to be there. If a redirect is really a gating check, doing it before you branch into slow, streamed subtrees keeps you on the faster HTTP-redirect path instead of the meta-refresh fallback.
Key Takeaways
| Question | Answer |
|---|---|
What does redirect() do internally? | Throws a NEXT_REDIRECT signal that Next.js's rendering pipeline intercepts |
| What status code does it send? | 307 by default (303 for Server Action form fallback submissions) |
| Where can I call it? | Server Components, Route Handlers, Server Functions, and Client Component render (not event handlers) |
What's the type parameter for? | Client-side history behavior only — replace (default outside Server Actions) or push (default inside them) |
| What's the #1 mistake? | Wrapping it in a try/catch that swallows its throw |
When should I use permanentRedirect() instead? | When the move is genuinely permanent and you want search engines to update their index |
When should I use notFound() instead? | When the honest answer is "this doesn't exist," not "go elsewhere" |
redirect() looks like the simplest function in the Next.js navigation toolkit, and in the common case, it is — call it, the user goes somewhere else, done. But the details around when you call it, what you wrap it in, and which of its siblings actually fits your situation are exactly the kind of thing that's invisible until it breaks a production form submission or quietly tanks a page's SEO. Once the throw-based mechanics click, though, the rest of the API — the status codes, the type parameter, the render-only restriction — all follows from that one idea, and the function stops being mysterious.


