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 `<meta http-equiv="refresh" content="0;url=/foo"/>` 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 | `<meta http-equiv="refresh" content="1;url=/foo"/>` | | `permanentRedirect()` | 308 | `<meta http-equiv="refresh" content="0;url=/foo"/>` | ref. https://developers.google.com/search/docs/crawling-indexing/301-redirects --------- Co-authored-by: JJ Kasper <jj@jjsweb.site> Co-authored-by: Tim Neutkens <tim@timneutkens.nl> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
bdfbde5db8
commit
efd8d22654
9 changed files with 166 additions and 12 deletions
|
@ -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.
|
|
@ -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).
|
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.
|
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
|
## Parameters
|
||||||
|
|
||||||
The `redirect` function accepts two arguments:
|
The `redirect` function accepts two arguments:
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { type RedirectError, isRedirectError } from './redirect'
|
||||||
|
|
||||||
|
export function getRedirectStatusCodeFromError<U extends string>(
|
||||||
|
error: RedirectError<U>
|
||||||
|
): number {
|
||||||
|
if (!isRedirectError(error)) {
|
||||||
|
throw new Error('Not a redirect error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.digest.split(';', 4)[3] === 'true' ? 308 : 307
|
||||||
|
}
|
|
@ -238,5 +238,5 @@ export function useSelectedLayoutSegment(
|
||||||
return selectedLayoutSegments[0]
|
return selectedLayoutSegments[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export { redirect } from './redirect'
|
export { redirect, permanentRedirect } from './redirect'
|
||||||
export { notFound } from './not-found'
|
export { notFound } from './not-found'
|
||||||
|
|
|
@ -8,17 +8,18 @@ export enum RedirectType {
|
||||||
replace = 'replace',
|
replace = 'replace',
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedirectError<U extends string> = Error & {
|
export type RedirectError<U extends string> = Error & {
|
||||||
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U}`
|
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U};${boolean}`
|
||||||
mutableCookies: ResponseCookies
|
mutableCookies: ResponseCookies
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRedirectError(
|
export function getRedirectError(
|
||||||
url: string,
|
url: string,
|
||||||
type: RedirectType
|
type: RedirectType,
|
||||||
|
permanent: boolean = false
|
||||||
): RedirectError<typeof url> {
|
): RedirectError<typeof url> {
|
||||||
const error = new Error(REDIRECT_ERROR_CODE) as RedirectError<typeof url>
|
const error = new Error(REDIRECT_ERROR_CODE) as RedirectError<typeof url>
|
||||||
error.digest = `${REDIRECT_ERROR_CODE};${type};${url}`
|
error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${permanent}`
|
||||||
const requestStore = requestAsyncStorage.getStore()
|
const requestStore = requestAsyncStorage.getStore()
|
||||||
if (requestStore) {
|
if (requestStore) {
|
||||||
error.mutableCookies = requestStore.mutableCookies
|
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
|
* 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
|
* @param url the url to redirect to
|
||||||
*/
|
*/
|
||||||
|
@ -37,7 +38,21 @@ export function redirect(
|
||||||
url: string,
|
url: string,
|
||||||
type: RedirectType = RedirectType.replace
|
type: RedirectType = RedirectType.replace
|
||||||
): never {
|
): 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<U extends string>(
|
||||||
): error is RedirectError<U> {
|
): error is RedirectError<U> {
|
||||||
if (typeof error?.digest !== 'string') return false
|
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 (
|
return (
|
||||||
errorCode === REDIRECT_ERROR_CODE &&
|
errorCode === REDIRECT_ERROR_CODE &&
|
||||||
(type === 'replace' || type === 'push') &&
|
(type === 'replace' || type === 'push') &&
|
||||||
typeof destination === 'string'
|
typeof destination === 'string' &&
|
||||||
|
(permanent === 'true' || permanent === 'false')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@ import {
|
||||||
getURLFromRedirectError,
|
getURLFromRedirectError,
|
||||||
isRedirectError,
|
isRedirectError,
|
||||||
} from '../../client/components/redirect'
|
} from '../../client/components/redirect'
|
||||||
|
import { getRedirectStatusCodeFromError } from '../../client/components/get-redirect-status-code-from-error'
|
||||||
import { addImplicitTags, patchFetch } from '../lib/patch-fetch'
|
import { addImplicitTags, patchFetch } from '../lib/patch-fetch'
|
||||||
import { AppRenderSpan } from '../lib/trace/constants'
|
import { AppRenderSpan } from '../lib/trace/constants'
|
||||||
import { getTracer } from '../lib/trace/tracer'
|
import { getTracer } from '../lib/trace/tracer'
|
||||||
|
@ -1478,11 +1479,13 @@ export async function renderToHTMLOrFlight(
|
||||||
)
|
)
|
||||||
} else if (isRedirectError(error)) {
|
} else if (isRedirectError(error)) {
|
||||||
const redirectUrl = getURLFromRedirectError(error)
|
const redirectUrl = getURLFromRedirectError(error)
|
||||||
|
const isPermanent =
|
||||||
|
getRedirectStatusCodeFromError(error) === 308 ? true : false
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
errorMetaTags.push(
|
errorMetaTags.push(
|
||||||
<meta
|
<meta
|
||||||
httpEquiv="refresh"
|
httpEquiv="refresh"
|
||||||
content={`0;url=${redirectUrl}`}
|
content={`${isPermanent ? 0 : 1};url=${redirectUrl}`}
|
||||||
key={error.digest}
|
key={error.digest}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -1562,7 +1565,7 @@ export async function renderToHTMLOrFlight(
|
||||||
let hasRedirectError = false
|
let hasRedirectError = false
|
||||||
if (isRedirectError(err)) {
|
if (isRedirectError(err)) {
|
||||||
hasRedirectError = true
|
hasRedirectError = true
|
||||||
res.statusCode = 307
|
res.statusCode = getRedirectStatusCodeFromError(err)
|
||||||
if (err.mutableCookies) {
|
if (err.mutableCookies) {
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { permanentRedirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
permanentRedirect('/redirect/result')
|
||||||
|
return <></>
|
||||||
|
}
|
41
test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js
Normal file
41
test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js
Normal file
|
@ -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 = <Component />
|
||||||
|
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 (
|
||||||
|
<div className="suspense">
|
||||||
|
<Suspense fallback="fallback">
|
||||||
|
<SuspenseyRedirect />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -421,6 +421,12 @@ createNextDescribe(
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(307)
|
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 () => {
|
it('should emit refresh meta tag for redirect page when streaming', async () => {
|
||||||
const html = await next.render('/redirect/suspense')
|
const html = await next.render('/redirect/suspense')
|
||||||
|
expect(html).toContain(
|
||||||
|
'<meta http-equiv="refresh" content="1;url=/redirect/result"/>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit refresh meta tag (peramnent) for redirect page when streaming', async () => {
|
||||||
|
const html = await next.render('/redirect/suspense-2')
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta http-equiv="refresh" content="0;url=/redirect/result"/>'
|
'<meta http-equiv="refresh" content="0;url=/redirect/result"/>'
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue