From efd8d2265475f37270c8c0d293bc6b5b467eaa4b Mon Sep 17 00:00:00 2001
From: Shohei Maeda <11495867+smaeda-ks@users.noreply.github.com>
Date: Tue, 29 Aug 2023 06:22:43 +0900
Subject: [PATCH] Add new `permanentRedirect` function in App Router (#54047)
for internal:
https://vercel.slack.com/archives/C03S8ED1DKM/p1691700057242999
### Problem
- The existing [`redirect()`
function](https://nextjs.org/docs/app/api-reference/functions/redirect)
can't control the status code.
- The existing [`redirect()`
function](https://nextjs.org/docs/app/api-reference/functions/redirect)
returns a 307 HTTP redirect response while it returns a 308-equivalent
meta tag `` in
streaming response (e.g., suspense boundary), making the behavior
inconsistent.
### Solution
Adding a new `permanentRedirect()` function and changing the meta tag
default accordingly.
| func | HTTP status | meta tag |
|---|:---:|---|
| `redirect()` | 307 | `` |
| `permanentRedirect()` | 308 | `` |
ref.
https://developers.google.com/search/docs/crawling-indexing/301-redirects
---------
Co-authored-by: JJ Kasper
Co-authored-by: Tim Neutkens
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
---
.../04-functions/permanentRedirect.mdx | 58 +++++++++++++++++++
.../04-functions/redirect.mdx | 4 ++
.../get-redirect-status-code-from-error.ts | 11 ++++
.../next/src/client/components/navigation.ts | 2 +-
.../next/src/client/components/redirect.ts | 36 +++++++++---
.../next/src/server/app-render/app-render.tsx | 7 ++-
.../app/redirect/servercomponent-2/page.js | 6 ++
.../app/redirect/suspense-2/page.js | 41 +++++++++++++
.../e2e/app-dir/navigation/navigation.test.ts | 13 +++++
9 files changed, 166 insertions(+), 12 deletions(-)
create mode 100644 docs/02-app/02-api-reference/04-functions/permanentRedirect.mdx
create mode 100644 packages/next/src/client/components/get-redirect-status-code-from-error.ts
create mode 100644 test/e2e/app-dir/navigation/app/redirect/servercomponent-2/page.js
create mode 100644 test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js
diff --git a/docs/02-app/02-api-reference/04-functions/permanentRedirect.mdx b/docs/02-app/02-api-reference/04-functions/permanentRedirect.mdx
new file mode 100644
index 0000000000..3162d2a8c7
--- /dev/null
+++ b/docs/02-app/02-api-reference/04-functions/permanentRedirect.mdx
@@ -0,0 +1,58 @@
+---
+title: permanentRedirect
+description: API Reference for the permanentRedirect function.
+---
+
+The `permanentRedirect` function allows you to redirect the user to another URL. `permanentRedirect` can be used in Server Components, Client Components, [Route Handlers](/docs/app/building-your-application/routing/route-handlers), and [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations).
+
+When used in a streaming context, this will insert a meta tag to emit the redirect on the client side. Otherwise, it will serve a 308 (Permanent) HTTP redirect response to the caller.
+
+If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.
+
+> **Good to know**: If you prefer to return a 307 (Temporary) HTTP redirect instead of 308 (Permanent), you can use the [`redirect` function](/docs/app/api-reference/functions/redirect) instead.
+
+## Parameters
+
+The `permanentRedirect` function accepts two arguments:
+
+```js
+permanentRedirect(path, type)
+```
+
+| Parameter | Type | Description |
+| --------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
+| `path` | `string` | The URL to redirect to. Can be a relative or absolute path. |
+| `type` | `'replace'` (default) or `'push'` (default in Server Actions) | The type of redirect to perform. |
+
+By default, `permanentRedirect` will use `push` (adding a new entry to the browser history stack) in [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations) and `replace` (replacing the current URL in the browser history stack) everywhere else. You can override this behavior by specifying the `type` parameter.
+
+The `type` parameter has no effect when used in Server Components.
+
+## Returns
+
+`permanentRedirect` does not return any value.
+
+## Example
+
+Invoking the `permanentRedirect()` function throws a `NEXT_REDIRECT` error and terminates rendering of the route segment in which it was thrown.
+
+```jsx filename="app/team/[id]/page.js"
+import { permanentRedirect } from 'next/navigation'
+
+async function fetchTeam(id) {
+ const res = await fetch('https://...')
+ if (!res.ok) return undefined
+ return res.json()
+}
+
+export default async function Profile({ params }) {
+ const team = await fetchTeam(params.id)
+ if (!team) {
+ permanentRedirect('/login')
+ }
+
+ // ...
+}
+```
+
+> **Good to know**: `permanentRedirect` does not require you to use `return permanentRedirect()` as it uses the TypeScript [`never`](https://www.typescriptlang.org/docs/handbook/2/functions.html#never) type.
diff --git a/docs/02-app/02-api-reference/04-functions/redirect.mdx b/docs/02-app/02-api-reference/04-functions/redirect.mdx
index 9bc5ddb73d..eb3a1337a4 100644
--- a/docs/02-app/02-api-reference/04-functions/redirect.mdx
+++ b/docs/02-app/02-api-reference/04-functions/redirect.mdx
@@ -5,8 +5,12 @@ description: API Reference for the redirect function.
The `redirect` function allows you to redirect the user to another URL. `redirect` can be used in Server Components, Client Components, [Route Handlers](/docs/app/building-your-application/routing/route-handlers), and [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations).
+When used in a streaming context, this will insert a meta tag to emit the redirect on the client side. Otherwise, it will serve a 307 HTTP redirect response to the caller.
+
If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.
+> **Good to know**: If you prefer to return a 308 (Permanent) HTTP redirect instead of 307 (Temporary), you can use the [`permanentRedirect` function](/docs/app/api-reference/functions/permanentRedirect) instead.
+
## Parameters
The `redirect` function accepts two arguments:
diff --git a/packages/next/src/client/components/get-redirect-status-code-from-error.ts b/packages/next/src/client/components/get-redirect-status-code-from-error.ts
new file mode 100644
index 0000000000..44e89ea751
--- /dev/null
+++ b/packages/next/src/client/components/get-redirect-status-code-from-error.ts
@@ -0,0 +1,11 @@
+import { type RedirectError, isRedirectError } from './redirect'
+
+export function getRedirectStatusCodeFromError(
+ error: RedirectError
+): number {
+ if (!isRedirectError(error)) {
+ throw new Error('Not a redirect error')
+ }
+
+ return error.digest.split(';', 4)[3] === 'true' ? 308 : 307
+}
diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts
index c4f977a233..4c28fa60d0 100644
--- a/packages/next/src/client/components/navigation.ts
+++ b/packages/next/src/client/components/navigation.ts
@@ -238,5 +238,5 @@ export function useSelectedLayoutSegment(
return selectedLayoutSegments[0]
}
-export { redirect } from './redirect'
+export { redirect, permanentRedirect } from './redirect'
export { notFound } from './not-found'
diff --git a/packages/next/src/client/components/redirect.ts b/packages/next/src/client/components/redirect.ts
index 5c002a8842..10e72bc1cc 100644
--- a/packages/next/src/client/components/redirect.ts
+++ b/packages/next/src/client/components/redirect.ts
@@ -8,17 +8,18 @@ export enum RedirectType {
replace = 'replace',
}
-type RedirectError = Error & {
- digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U}`
+export type RedirectError = Error & {
+ digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U};${boolean}`
mutableCookies: ResponseCookies
}
export function getRedirectError(
url: string,
- type: RedirectType
+ type: RedirectType,
+ permanent: boolean = false
): RedirectError {
const error = new Error(REDIRECT_ERROR_CODE) as RedirectError
- error.digest = `${REDIRECT_ERROR_CODE};${type};${url}`
+ error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${permanent}`
const requestStore = requestAsyncStorage.getStore()
if (requestStore) {
error.mutableCookies = requestStore.mutableCookies
@@ -27,9 +28,9 @@ export function getRedirectError(
}
/**
- * When used in a React server component, this will insert a meta tag to
+ * When used in a streaming context, this will insert a meta tag to
* redirect the user to the target page. When used in a custom app route, it
- * will serve a 302 to the caller.
+ * will serve a 307 to the caller.
*
* @param url the url to redirect to
*/
@@ -37,7 +38,21 @@ export function redirect(
url: string,
type: RedirectType = RedirectType.replace
): never {
- throw getRedirectError(url, type)
+ throw getRedirectError(url, type, false)
+}
+
+/**
+ * When used in a streaming context, this will insert a meta tag to
+ * redirect the user to the target page. When used in a custom app route, it
+ * will serve a 308 to the caller.
+ *
+ * @param url the url to redirect to
+ */
+export function permanentRedirect(
+ url: string,
+ type: RedirectType = RedirectType.replace
+): never {
+ throw getRedirectError(url, type, true)
}
/**
@@ -52,12 +67,15 @@ export function isRedirectError(
): error is RedirectError {
if (typeof error?.digest !== 'string') return false
- const [errorCode, type, destination] = (error.digest as string).split(';', 3)
+ const [errorCode, type, destination, permanent] = (
+ error.digest as string
+ ).split(';', 4)
return (
errorCode === REDIRECT_ERROR_CODE &&
(type === 'replace' || type === 'push') &&
- typeof destination === 'string'
+ typeof destination === 'string' &&
+ (permanent === 'true' || permanent === 'false')
)
}
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index 851634a17e..08d683e423 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -49,6 +49,7 @@ import {
getURLFromRedirectError,
isRedirectError,
} from '../../client/components/redirect'
+import { getRedirectStatusCodeFromError } from '../../client/components/get-redirect-status-code-from-error'
import { addImplicitTags, patchFetch } from '../lib/patch-fetch'
import { AppRenderSpan } from '../lib/trace/constants'
import { getTracer } from '../lib/trace/tracer'
@@ -1478,11 +1479,13 @@ export async function renderToHTMLOrFlight(
)
} else if (isRedirectError(error)) {
const redirectUrl = getURLFromRedirectError(error)
+ const isPermanent =
+ getRedirectStatusCodeFromError(error) === 308 ? true : false
if (redirectUrl) {
errorMetaTags.push(
)
@@ -1562,7 +1565,7 @@ export async function renderToHTMLOrFlight(
let hasRedirectError = false
if (isRedirectError(err)) {
hasRedirectError = true
- res.statusCode = 307
+ res.statusCode = getRedirectStatusCodeFromError(err)
if (err.mutableCookies) {
const headers = new Headers()
diff --git a/test/e2e/app-dir/navigation/app/redirect/servercomponent-2/page.js b/test/e2e/app-dir/navigation/app/redirect/servercomponent-2/page.js
new file mode 100644
index 0000000000..524221bae5
--- /dev/null
+++ b/test/e2e/app-dir/navigation/app/redirect/servercomponent-2/page.js
@@ -0,0 +1,6 @@
+import { permanentRedirect } from 'next/navigation'
+
+export default function Page() {
+ permanentRedirect('/redirect/result')
+ return <>>
+}
diff --git a/test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js b/test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js
new file mode 100644
index 0000000000..d9388ce25a
--- /dev/null
+++ b/test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js
@@ -0,0 +1,41 @@
+import { Suspense } from 'react'
+import { permanentRedirect } from 'next/navigation'
+
+function createSuspenseyComponent(Component, { timeout = 0, expire = 10 }) {
+ let result
+ let promise
+ return function Data() {
+ if (result) return result
+ if (!promise)
+ promise = new Promise((resolve) => {
+ setTimeout(() => {
+ result =
+ setTimeout(() => {
+ result = undefined
+ promise = undefined
+ }, expire)
+ resolve()
+ }, timeout)
+ })
+ throw promise
+ }
+}
+
+function Redirect() {
+ permanentRedirect('/redirect/result')
+ return <>>
+}
+
+const SuspenseyRedirect = createSuspenseyComponent(Redirect, {
+ timeout: 300,
+})
+
+export default function () {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/navigation/navigation.test.ts b/test/e2e/app-dir/navigation/navigation.test.ts
index cda4360d12..67beabf483 100644
--- a/test/e2e/app-dir/navigation/navigation.test.ts
+++ b/test/e2e/app-dir/navigation/navigation.test.ts
@@ -421,6 +421,12 @@ createNextDescribe(
})
expect(res.status).toBe(307)
})
+ it('should respond with 308 status code if permanent flag is set', async () => {
+ const res = await next.fetch('/redirect/servercomponent-2', {
+ redirect: 'manual',
+ })
+ expect(res.status).toBe(308)
+ })
})
})
@@ -551,6 +557,13 @@ createNextDescribe(
it('should emit refresh meta tag for redirect page when streaming', async () => {
const html = await next.render('/redirect/suspense')
+ expect(html).toContain(
+ ''
+ )
+ })
+
+ it('should emit refresh meta tag (peramnent) for redirect page when streaming', async () => {
+ const html = await next.render('/redirect/suspense-2')
expect(html).toContain(
''
)