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( '' )