Redirects
Send visitors to a new address with redirect(), permanentRedirect(), next.config.ts and proxy.ts, and learn what 307, 308 and 303 tell the browser.
- 01Redirect from your own codeCall redirect() and permanentRedirect() in a page, a Server Action and a Route Handler, and say what each one answers.
- 02Read a redirect's answerTell 307, 308 and 303 apart from a recording, and say what each does to the method, the cache and search results.
- 03Pick the right place for itChoose between next.config.ts, proxy.ts and your own code, and keep redirect() out of try/catch.
Sending a visitor to another address
A redirect sends a visitor who asks for one address to another one. The tea shop needs a few. Its old menu page moved to /teas, and the tea it once called Peppermint is now Mint tea, at /teas/mint. People still have the old addresses in bookmarks, search results and links on other sites, and those should keep working.
Next.js gives you two functions for it, redirect() and permanentRedirect(), from next/navigation. Call one while a page renders, and the page stops there and the visitor goes to the address you passed. Here is the shop with both. Like every example in this lesson, it's a real Next.js 16.4.3 project. It was built with next build and run with next start, and what it sent back was recorded.
app/menu/page.tsximport { redirect } from "next/navigation"; export default function MenuPage() { redirect("/teas");}GET /menu307
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1location/teasx-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived holds no page, only 16 scripts, their code left out here. The browser follows the location header instead
<!DOCTYPE html>
<html id="__next_error__">
<head>
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In app/menu/page.tsx, the file shown first, line 1 imports redirect, and line 4 calls it with the new address, "/teas". The page returns nothing, and it doesn't need to: redirect() never returns, so no line after it would run. Click app/teas/peppermint/page.tsx: the same shape, with permanentRedirect("/teas/mint") on line 4.
The Network tab is open on /menu. A browser and a server talk in requests and responses, and every response starts with a status code: a three-digit number that says how the request went. 200 means "here is the page". /menu answered 307 instead, with a header named location whose value is /teas. (A header is a name and a value that come before the response's body.) Pick /teas/peppermint above the page: 308, with location: /teas/mint. Neither answer holds a page. Pick /teas: 200, and the page.
Now open the Steps tab. The recorder also opened /menu in real Chrome, and that one step made two document requests, which means whole HTML pages: GET /menu answered 307, and GET /teas answered 200. The address bar ends at /teas. The visitor never sees anything at /menu; the browser followed the location header on its own.
location header. The browser asks for that address next, and shows the page that comes back.A shop moves down the road and leaves a note on its old door. Customers who knock read the note and walk to the new address, without thinking about it. Some notes say the shop will be back next week. Others say it moved for good, and regulars cross out the old address in their notebooks.
- In real life: Knocking at the shop's old address
- In Next.js: The browser asks for
/menu - In real life: A note on the door: "We're at 12 Hill Road"
- In Next.js: A
307withlocation: /teas, and no page - In real life: Walking to the address on the note
- In Next.js: The browser asks for
/teason its own - In real life: "Back next week" or "We moved for good"
- In Next.js:
307fromredirect(), or308frompermanentRedirect()
A redirect sends the visitor on to another address. The plainest kind is an HTTP redirect: an answer with no page, only a status code from 300 to 399 and the new address in the location header, which the browser follows on its own. Before any of the page has been sent, redirect() answers 307, "use this address for now", and permanentRedirect() answers 308, "use this address from now on". Two cases answer 200 instead and move the visitor another way: a page that has already started streaming, and a Server Action called with the page's JavaScript.
redirect() and permanentRedirect() in a page
Open the Terminal tab of the example above. The build lists ○ /menu and ○ /teas/peppermint. ○ means prerendered as static content: Next.js ran both pages during next build. Each one called its function, and Next.js stored the redirect itself, the status and the address, as that page's answer. Back in the Network tab, /menu carries the same x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000 as /teas. It's sent like any static page, without running MenuPage again.
A real page usually redirects only sometimes, after it has read something. The tea shop's account page shows a visitor's orders, and sends a visitor who isn't signed in to /sign-in. Who is signed in comes from a cookie named session. The browser keeps it and sends it with each request, so the server only learns the visitor's current session when a request comes in:
app/account/page.tsximport { Suspense } from "react";import { cookies } from "next/headers";import { redirect } from "next/navigation"; async function Orders() { const name = (await cookies()).get("session")?.value; if (!name) { redirect("/sign-in"); } return <p>Signed in as {name}. You have 2 orders.</p>;} export default function AccountPage() { return ( <main> <h1>Your account</h1> <Suspense fallback={<p>Checking who you are…</p>}> <Orders /> </Suspense> </main> );}GET /account200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas">Teas</a></nav>
<!--&-->
<!--&-->
<main>
<h1>Your account</h1>
<!--$?-->
<template id="B:0"></template>
<p>Checking who you are…</p>
<!--/$-->
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/sign-in">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<script>…</script>
<script>…</script>
</body>
</html>Line 6 reads the cookie with cookies(). Lines 7 to 9 call redirect("/sign-in") when there is none. Otherwise line 10 shows the orders. Orders waits for request-time data, so with Cache Components on, as in every project in this course, it sits inside a Suspense on lines 17 to 19, with Checking who you are… as its fallback. (Loading UI explains why the build insists on one.)
Now read the Network tab. Without the cookie, /account answered 200, not 307. Here's why. The page's static shell, the part made during the build with the heading and the fallback, left the server first, and the status code goes out with the first part of a response. By the time Orders called redirect(), the 200 was already sent. So this answer isn't an HTTP redirect: no 3xx status, and no location header. Next.js put the redirect in the rest of the response instead: near the end of the HTML is <meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/sign-in">, a tag that tells a browser to load /sign-in after 1 second, even without the page's JavaScript.
The Steps tab shows where Chrome ended up: GET /account answered 200, then Chrome loaded GET /sign-in as a new page, and the address bar ends at /sign-in. The visitor got there. But a search engine or a link checker that reads only the status sees a working page at /account. Pick /account, signed in as Asha: with the cookie session=Asha, the same address shows Signed in as Asha. You have 2 orders.
Next.js's docs say it the same way: a streamed response starts with 200, and once it has started, the status can't change. For a real 307, decide before the page renders. That's what proxy.ts is for, later in this lesson.
redirect() also works while a Client Component renders. In an event handler, like a button's onClick, the docs say to use router.push() from useRouter instead, as Linking and navigating shows.
After a form is sent: Server Actions and Route Handlers
A Server Action is a function marked "use server" that runs on the server when a form calls it. Your first Next.js app ended one with redirect("/") and saw a 303. Here is the tea shop's checkout, and this time the recorder also pressed the button in Chrome, with the page's JavaScript running:
app/checkout/actions.ts"use server"; import { redirect } from "next/navigation";import { saveOrder } from "../_data/orders"; export async function placeOrder(formData: FormData) { await saveOrder(String(formData.get("tea"))); redirect("/thanks");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Your tea is on its way.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
- Announced to screen readers
Thank you!
Requests the page made during this step (1), without the files it loaded
POST/checkout200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22checkout%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
- next-action
- 40518e31cdede3714edb023553298cd30bf0370646
In app/checkout/actions.ts, line 1 marks every function in the file as a Server Action. Line 7 saves the order with saveOrder from app/_data/orders.ts, a stand-in for a database. Line 8 sends the shopper to /thanks. In app/checkout/page.tsx, line 7 gives the form action={placeOrder}.
First, the form without JavaScript. Open the Network tab and pick POST /checkout, labeled Place order, before JavaScript: the recorder posted the form the way a browser does before the page's JavaScript has loaded. The answer is 303 with location: /thanks. 303, "see other", tells the browser to ask for the new address with a GET, a plain request for a page.
Then with JavaScript. The Steps tab is open on step 2, the click on Place order. It made one request: a fetch, not a document, POST /checkout with a next-action header that names the action. It answered 200 with text/x-component, an RSC payload: the data React needs to show a page, which Next.js's router asks for instead of HTML. There is no request for /thanks, yet the page shows Thank you!. The action's answer already held the /thanks page, and the router showed it in place, as after a click on a Link. One round trip, and no new HTML page. So this isn't an HTTP redirect either: the status is 200, and the router does the moving.
Step 3 presses the browser's Back button, and the shopper is on Checkout again, with no request at all. In a Server Action, redirect() adds the new address to the browser's history, so Back returns to the form. Everywhere else it replaces the current entry. (A second argument, RedirectType.push or RedirectType.replace, changes that; the docs say it has no effect in Server Components.)
In a Route Handler
A Route Handler is a route.ts file that answers requests with code instead of a page. It can call redirect() too:
app/go/route.tsimport { redirect } from "next/navigation"; export function GET() { redirect("/teas");}GET /go307
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-nextjs-cacheHITcache-controls-maxage=31536000location/teas
No HTML arrived: a redirect sends none
Line 3 exports GET, the function for GET requests, and line 4 redirects. /go answered 307 with location: /teas, as a prerendered page does. Pick /go/mint: its handler, in app/go/mint/route.ts, calls permanentRedirect() and answers 308. The Terminal tab lists ○ /go and ○ /go/mint: a GET handler that reads nothing from the request is run during the build, like a page. One case differs: when an HTML form is posted to a Route Handler, Next.js answers 303, as for a Server Action, so the browser asks for the new address with a GET instead of posting the form again.
307, 308 and 303: why the number matters
Where a redirect runs, and when, decides what comes back. Each row is one case from this lesson's recordings: what Next.js answered, and what the browser did next.
| Where and when | What Next.js answers | What the browser does next |
|---|---|---|
redirect() or permanentRedirect() in a page that was prerendered, or hasn't started streaming | 307 or 308, with location: an HTTP redirect. | Asks for the new address on its own. |
| The same in a page that has already started streaming | 200, with a <meta http-equiv="refresh"> tag in the rest of the HTML. | Loads the new address as a new page. |
| A Server Action, called with the page's JavaScript | 200, with an RSC payload that already holds the new page. | The router shows the new page in place, with no new HTML page. |
| A form posted without JavaScript, to a Server Action or a Route Handler | 303, with location: an HTTP redirect. | Asks for the new address with a GET. |
redirect() or permanentRedirect() in a Route Handler for GET | 307 or 308, with location: an HTTP redirect. | Asks for the new address on its own. |
redirects in next.config.ts, or NextResponse.redirect in proxy.ts | 307 or 308 before any page renders (proxy.ts: 307 unless you pass another). | Asks for the new address on its own. |
Only the 3xx answers are HTTP redirects. Their three codes all send the browser somewhere else; they differ in what the browser does next, and in what it and search engines remember. Each row is one status code:
| Status | What it tells the browser | Who sends it in Next.js |
|---|---|---|
307 Temporary Redirect | Ask the new address this time, with the same method and body. The old address stays the right one. | redirect() in a page that hasn't started streaming or in a GET Route Handler, permanent: false in next.config.ts, and NextResponse.redirect in proxy.ts unless you pass another status. |
308 Permanent Redirect | Ask the new address with the same method and body, and use it from now on. Browsers may remember it, and search engines replace the old address. | permanentRedirect() in a page that hasn't started streaming or in a GET Route Handler, and permanent: true in next.config.ts. |
303 See Other | Ask the new address with a GET, whatever the first request was. The form's body isn't sent again. | redirect() and permanentRedirect() after a form is posted to a Server Action or a Route Handler. |
302 Found and 301 Moved Permanently | The older temporary and permanent codes. Some browsers turn a POST into a GET when they follow them. | None of these by default. A next.config.ts redirect can ask for one with statusCode instead of permanent. |
The method: does the form go again?
Every request has a method: GET to ask for something, POST to send something, like a form. A 307 or a 308 tells the browser to repeat the same request at the new address, method and body included. The tea shop's newsletter form shows it. Old copies of the form, on other sites, still post to /signup, so next.config.ts sends that address on to /subscribe, where a Route Handler saves the email:
app/subscribe/route.tsimport { redirect } from "next/navigation"; export async function POST(request: Request) { const form = await request.formData(); console.log("New subscriber:", form.get("email")); redirect("/thanks");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Your tea is on its way.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (7), without the files it loaded
POST/signup307a whole pagePOST/subscribe303a whole pageGET/thanks200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /thanks
GET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /thanks
GET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /__PAGE__
- next-url
- /thanks
GET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/__PAGE__
- next-url
- /thanks
In app/subscribe/route.ts, line 3 exports POST, line 4 reads the form, line 5 logs the email, and line 6 redirects to /thanks. In app/newsletter/page.tsx, line 5 is a plain HTML form that posts to /signup. In next.config.ts, line 7 sends /signup to /subscribe with permanent: false, which answers 307.
The Steps tab is open on the click on Subscribe. Chrome made three document requests for it. POST /signup answered 307, so Chrome sent POST /subscribe: the same method, with the form again. The Route Handler answered 303, so Chrome asked GET /thanks, and showed the page. Without the 303, the form would have gone a third time, to /thanks.
307 kept the method, so the form was posted again, to the new address. The 303 told the browser to ask with a GET, so the form wasn't sent a third time.That's why Next.js answers 303 after a posted form, from a Server Action or a Route Handler, even when your code called permanentRedirect(). A 307 would send the order or the email again. Next.js's source says the same, in a comment above the line that picks 303 for a Route Handler (linked at the end of this lesson).
Remembered or not
Temporary or permanent also matters after the visit. A permanent redirect says the old address is gone for good. Browsers may remember a 308 and go straight to the new address next time, without asking the old one. Search engines replace the old address with the new one in their results. A temporary redirect asks them to keep the old address. So use permanentRedirect() or permanent: true only when the move is final: a browser that remembered it may keep skipping the old address even after you change your mind.
Check each one yourself. Every version below is one of this lesson's recordings. Before you click, guess its status.
app/menu/page.tsximport { redirect } from "next/navigation"; export default function MenuPage() { redirect("/teas");}GET /menu307
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1location/teasx-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived holds no page, only 16 scripts, their code left out here. The browser follows the location header instead
<!DOCTYPE html>
<html id="__next_error__">
<head>
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Line 4 calls redirect("/teas") while the page renders. The answer is 307 with location: /teas: a temporary redirect.
307 and 308 come from code that runs before anything of the page was sent, 303 from a posted form, and 200 from a page that had already started streaming. Older sites use 302 and 301 for temporary and permanent; some browsers turn a POST into a GET on those, which is the reason Next.js uses 307 and 308.
A list of redirects in next.config.ts
When you know the moves in advance, you don't need a page for each old address. next.config.ts, the project's settings file, can hold a list of them:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, async redirects() { return [ { source: "/menu", destination: "/teas", permanent: false }, { source: "/blog/:slug", destination: "/journal/:slug", permanent: true }, ]; },}; export default nextConfig;GET /menu307
- Response headers
location/teas
No HTML arrived: the body is only the new address, as plain text
/teasLines 4 and 5 are the settings create-next-app wrote, kept as they were. Line 6 adds redirects, an async function that returns the list. Each entry has a source, the old address, a destination, the new one, and permanent. Line 8 sends /menu to /teas for now. Line 9 moves every blog post for good: :slug matches one part of the address, whatever it is, and the destination reuses it.
In the Network tab, /menu answered 307 with location: /teas. Pick /blog/brewing-green-tea: 308, with location: /journal/brewing-green-tea. Pick the one with ?from=mail: the query came along, location: /journal/brewing-green-tea?from=mail. These answers have no HTML at all, only the new address as plain text. The Terminal tab has no /menu and no /blog: the list isn't made of routes. The docs say Next.js checks it before it looks for a page or a file, so an entry here wins even over a page at the same address.
Redirecting in proxy.ts: a first look
Some decisions depend on the request, and must still come before the page renders: the account page from earlier, for one. proxy.ts is the place for them. It's a file at the project's root, beside the app folder, whose function runs before a request reaches its route. (Before Next.js 16 it was called middleware.ts.) Here it guards the account page:
proxy.tsimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { if (!request.cookies.has("session")) { return NextResponse.redirect(new URL("/sign-in", request.url)); } return NextResponse.next();} export const config = { matcher: "/account" };GET /account307
- Response headers
location/sign-in
No HTML arrived: the body is only the new address, as plain text
/sign-inLine 3 exports a function named proxy, which gets the request. Line 4 checks for the session cookie, and line 5 answers with NextResponse.redirect and the full address of /sign-in, built from the request's own. (The Network tab shows it as /sign-in: Next.js sent the address on the same site as a path.) Line 7 lets every other request through. Line 10 is the matcher: proxy.ts runs only for /account. The page is the same as before.
Now /account without the cookie answers 307 with location: /sign-in: the page never started, so the status could still be a redirect. With session=Asha, it answers 200 with the orders. The Terminal tab adds a line under the routes, ƒ Proxy (Middleware), and the Steps tab shows Chrome's GET /account answered 307, then GET /sign-in.
Next.js's docs list the order: the redirects in next.config.ts first, then proxy.ts, then the routes. proxy.ts can do much more, and has its own lesson, proxy.ts. One warning already: a Server Action is a POST to its page, so a matcher that skips the page skips the action too. The docs say to check who is signed in inside each Server Action as well, not only in proxy.ts.
Why redirect() must not sit in try/catch
How does redirect() stop a page or an action in the middle? It throws: it creates an error and throws it, the way any JavaScript error stops the code around it (try...catch in the JavaScript course shows how). Next.js catches that error further up, outside your code, and turns it into the redirect. Here is the function, exactly as Next.js 16.4.3 has it:
export function redirect(
/** The URL to redirect to */
url: string,
type?: RedirectType
): never {
type ??= actionAsyncStorage?.getStore()?.isAction ? 'push' : 'replace'
throw getRedirectError(url, type, RedirectStatusCode.TemporaryRedirect)
}Line 5 says it returns never: a TypeScript type for a function that never comes back. Line 6 picks the history entry: push inside a Server Action, replace elsewhere. Line 8 throws. The error's message is NEXT_REDIRECT, and it carries the address and the status for Next.js to read.
So a catch around redirect() catches the redirect. The checkout's action wants to log saving errors, and wraps everything in try:
app/checkout/actions.tsNext.jsapp/checkout/actions.ts"use server"; import { redirect } from "next/navigation";import { saveOrder } from "../_data/orders"; export async function placeOrder(formData: FormData) { try { await saveOrder(String(formData.get("tea"))); redirect("/thanks"); } catch (error) { console.error("Could not place the order:", error); }}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Focus
- the button “Place order”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/checkout200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22checkout%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
- next-action
- 40a218f270e59332deab2f5aacd531e8048bba3e88
Line 9 throws to send the shopper on, and the catch on line 10 catches it. The order is saved, but the shopper stays on Checkout, and next start prints Could not place the order: Error: NEXT_REDIRECT.
In the first version, the Steps tab ends step 2 on /checkout: the order was saved, and the shopper stayed. The Network tab's form without JavaScript got 200 and the Checkout page again, not a 303. The Terminal tab shows what next start printed, twice, once for each order: Could not place the order: Error: NEXT_REDIRECT. The catch on line 10 got Next.js's error, logged it as a failure, and the action ended normally.
redirect() throws an error to stop your code. Inside try, your catch gets that error first, and Next.js never hears of the redirect. Outside it, the error reaches Next.js, which turns it into the redirect.There are two fixes, the second and third versions:
- Call
redirect()after thetry/catch. Only the work that can fail goes intry, and thecatchon line 9 returns early. Line 13 runs outside it. - Rethrow Next.js's errors.
unstable_rethrow(error), on line 11 as the first line of thecatch, throws the error again if it's one of Next.js's own, like the one fromredirect()ornotFound()(from Not found, forbidden and unauthorized), and does nothing otherwise.unstable_in its name means it may still change.
Both answer the form with 303 and send the shopper to /thanks in Chrome. The same mistake in a page is quieter still:
app/menu/page.tsximport { redirect } from "next/navigation"; export default function MenuPage() { try { redirect("/teas"); } catch { return <p>The menu has moved.</p>; }}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (6/6) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /menu├ ○ /teas└ ○ /teas/mint ○ (Static) prerendered as static contentThe build finished with no warning and lists ○ /menu. The catch on line 6 caught the redirect during the build, so the page Next.js stored is the one on line 7: in the Network tab, /menu answers 200 with The menu has moved. and no location. Nothing tells you, except the page itself.
Planning the tea shop's redirects
Each redirect has a best place: the earliest one that knows enough to decide. Each row is one place, in the order a request meets them:
| Where | When it runs | Good for |
|---|---|---|
redirects in next.config.ts | First, before Next.js looks for a page or a file. | Addresses that moved, listed when you build: an old page, a whole old folder of posts. |
proxy.ts | Next, still before the route renders. | Decisions from the request itself: no session cookie, the visitor's language, a long list read from a store. |
redirect() in a page | While the page renders. | What only the page knows, like data that says a tea was renamed. Before any part streams, or the answer stays 200. |
redirect() in a Server Action | After the action has done its work. | Sending the shopper on after a change, like a placed order. |
redirect() in a Route Handler | When the handler runs. | Endpoints that answer with a new address, like a short link. |
Sort the tea shop's redirects: where does each one belong?
- The old
/menupage is now/teas. - Every post moved from
/blog/<post>to/journal/<post>, for good. - Visitors without a
sessioncookie must not reach/account. - Send visitors to
/fror/enfrom their browser's language. - After an order is saved, show the thank-you page.
- A tea's page finds in the database that the tea was renamed.
/gosends the visitor to today's tea.
Sort each redirect: the list in next.config.ts, proxy.ts, or redirect() in a page, an action or a Route Handler.
Common mix-ups
- "Every redirect answers with a 3xx status." No. Two answer
200: a page that had started streaming adds a meta refresh tag, and a Server Action called with JavaScript sends an RSC payload the router follows. - "You write
return redirect(…)." No.redirect()throws and never returns, and its type,never, tells TypeScript so. Noreturnis needed. - "A redirect in a page always answers
307." No. If the page has started to stream, as the account page had, the answer is200with a<meta http-equiv="refresh">tag. Decide in proxy.ts for a real307. - "
permanentRedirect()in a Server Action answers308." No. A posted form gets303either way, and with JavaScript the router moves in place. - "
307is the same as302." No. A307keeps the method and the body; some browsers turn aPOSTinto aGETon a302. - "
redirect()works in a button'sonClick." No. The docs say to use it while rendering, androuter.push()in event handlers. - "proxy.ts protects everything behind it." No. A matcher can miss a Server Action's
POST. Check who is signed in inside each action too. - "A permanent redirect is easy to take back." No. Browsers may remember a
308, and search engines replace the old address. Use it only for final moves.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page.
This is app/menu/page.tsx. Which status code and which location does /menu answer with?
import { redirect } from "next/navigation";
export default function MenuPage() {
redirect("/teas");
}
307 with location: /teas. redirect() is temporary, and line 4 names the new address. The recording of the shop shows exactly that, and Chrome ends at /teas.
This is app/teas/peppermint/page.tsx. Which status code does /teas/peppermint answer with?
import { permanentRedirect } from "next/navigation";
export default function PeppermintPage() {
permanentRedirect("/teas/mint");
}
308, with location: /teas/mint. The tea was renamed for good, so the page uses permanentRedirect(), and search engines can replace the old address.
Every post moved for good from /blog/<post> to /journal/<post>. Add the redirect to next.config.ts. Then: which location does /blog/brewing-green-tea?from=mail get?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
async redirects() {
return [
// /blog/<post> moved for good to /journal/<post>
];
},
};
export default nextConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
async redirects() {
return [
{ source: "/menu", destination: "/teas", permanent: false },
{ source: "/blog/:slug", destination: "/journal/:slug", permanent: true },
];
},
};
export default nextConfig;
Line 9 moves every post for good. The recording shows /blog/brewing-green-tea?from=mail answering 308 with location: /journal/brewing-green-tea?from=mail: the slug is copied, and the query comes along.
The checkout's action now ends with permanentRedirect("/thanks"). A shopper presses Place order before the JavaScript loads. Which status code comes back?
"use server";
import { permanentRedirect } from "next/navigation";
import { saveOrder } from "../_data/orders";
export async function placeOrder(formData: FormData) {
await saveOrder(String(formData.get("tea")));
permanentRedirect("/thanks");
}
303, with location: /thanks. A 308 would make the browser post the order again to /thanks, so Next.js answers 303 after a form, temporary or permanent. The recording of this action shows 303.
Shoppers stay on Checkout after Place order, and the server logs Error: NEXT_REDIRECT. Which line must move out of try?
"use server";
import { redirect } from "next/navigation";
import { saveOrder } from "../_data/orders";
export async function placeOrder(formData: FormData) {
try {
await saveOrder(String(formData.get("tea")));
redirect("/thanks");
} catch (error) {
console.error("Could not place the order:", error);
}
}
"use server";
import { redirect } from "next/navigation";
import { saveOrder } from "../_data/orders";
export async function placeOrder(formData: FormData) {
try {
await saveOrder(String(formData.get("tea")));
} catch (error) {
console.error("Could not place the order:", error);
return;
}
redirect("/thanks");
}
Line 9, redirect("/thanks"). Inside try, the catch on line 10 gets its error. After the try/catch, as on line 13 here, Next.js gets it, and the shopper reaches /thanks.
A visitor without a session cookie asks for /account. Which status code does the page answer with? Then, how would you make it a 307?
import { Suspense } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
async function Orders() {
const name = (await cookies()).get("session")?.value;
if (!name) {
redirect("/sign-in");
}
return <p>Signed in as {name}. You have 2 orders.</p>;
}
export default function AccountPage() {
return (
<main>
<h1>Your account</h1>
<Suspense fallback={<p>Checking who you are…</p>}>
<Orders />
</Suspense>
</main>
);
}
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
if (!request.cookies.has("session")) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
return NextResponse.next();
}
export const config = { matcher: "/account" };
200. The shell, with the heading and the fallback, left first, so the status was already sent; the redirect came later as a <meta http-equiv="refresh"> tag. To answer 307, decide in proxy.ts, as here, before the page starts.
Check your understanding
For each question, ask where the redirect runs, and whether the response had already started.
Question 1 of 7This is
app/menu/page.tsx. What does/menuanswer with?Read the code, then predictTSXimport { redirect } from "next/navigation"; export default function MenuPage() { redirect("/teas"); }Choose an answer to see the explanation.
Question 2 of 7A shopper presses Place order before the page's JavaScript has loaded, and the action calls
permanentRedirect("/thanks"). Which status comes back?Read the code, then predictTS"use server"; import { permanentRedirect } from "next/navigation"; import { saveOrder } from "../_data/orders"; export async function placeOrder(formData: FormData) { await saveOrder(String(formData.get("tea"))); permanentRedirect("/thanks"); }Choose an answer to see the explanation.
Question 3 of 7With the page's JavaScript running, what does a click on Place order make the browser send?
Choose an answer to see the explanation.
Question 4 of 7Why does Next.js answer a posted form with 303 instead of 307?
Choose an answer to see the explanation.
Question 5 of 7In next.config.ts,
{ source: "/blog/:slug", destination: "/journal/:slug", permanent: true }. What does/blog/brewing-green-tea?from=mailanswer?Choose an answer to see the explanation.
Question 6 of 7The account page reads the cookie inside
Suspenseand callsredirect("/sign-in")when it's missing. What status does/accountanswer with?Read the code, then predictTSXimport { Suspense } from "react"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; async function Orders() { const name = (await cookies()).get("session")?.value; if (!name) { redirect("/sign-in"); } return <p>Signed in as {name}. You have 2 orders.</p>; } export default function AccountPage() { return ( <main> <h1>Your account</h1> <Suspense fallback={<p>Checking who you are…</p>}> <Orders /> </Suspense> </main> ); }Choose an answer to see the explanation.
Question 7 of 7This action keeps the shopper on Checkout after Place order. Why?
Read the code, then predictTS"use server"; import { redirect } from "next/navigation"; import { saveOrder } from "../_data/orders"; export async function placeOrder(formData: FormData) { try { await saveOrder(String(formData.get("tea"))); redirect("/thanks"); } catch (error) { console.error("Could not place the order:", error); } }Choose an answer to see the explanation.
Key takeaways
- An HTTP redirect is an answer with no page: a 3xx status code and a
locationheader. The browser asks for the new address on its own. redirect()answers307andpermanentRedirect()answers308, from a page that hasn't started streaming or aGETRoute Handler. After a posted form, both answer303.- Once a page has started streaming, its redirect answers
200with a<meta http-equiv="refresh">tag, and the browser loads the new address as a new page. - With JavaScript, a Server Action that redirects sends one
POST, and its answer already holds the new page. The router shows it in place, and adds it to the history. 307and308keep the method and the body;303makes it aGET. Permanent redirects are remembered by browsers and search engines.- Known moves go in
next.config.ts; decisions from the request go inproxy.ts; what only the page or the action knows goes in your code. redirect()throws. Keep it out oftry, or callunstable_rethrow(error)first in thecatch.
Remember the one-liner.
Redirect as early as you can: then it's a real 307 for now, 308 for good, or 303 after a form; once a page streams, it's a 200 with a meta tag.
These links point at release v16.4.3, the version every example here was built with. redirect() and permanentRedirect() are at lines 33 to 61 of redirect.ts. A Server Action's redirect becomes a 303, or a 200 with the new page for the router, at lines 1343 to 1376 of action-handler.ts, and a Route Handler picks 303 for a posted form at lines 438 to 446 of module.ts. unstable_rethrow is in unstable-rethrow.ts.
Coming next: Templates and the file hierarchy. It shows how a folder's special files nest inside each other, and template.tsx, a layout that starts fresh on each page.