Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new permanentRedirect function in App Router #54047

Merged
merged 17 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/02-app/02-api-reference/04-functions/permanentRedirect.mdx
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/02-app/02-api-reference/04-functions/redirect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,5 +238,5 @@ export function useSelectedLayoutSegment(
return selectedLayoutSegments[0]
}

export { redirect } from './redirect'
export { redirect, permanentRedirect } from './redirect'
export { notFound } from './not-found'
36 changes: 27 additions & 9 deletions packages/next/src/client/components/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ export enum RedirectType {
replace = 'replace',
}

type RedirectError<U extends string> = Error & {
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U}`
export type RedirectError<U extends string> = Error & {
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U};${boolean}`
mutableCookies: ResponseCookies
}

export function getRedirectError(
url: string,
type: RedirectType
type: RedirectType,
permanent: boolean = false
): 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()
if (requestStore) {
error.mutableCookies = requestStore.mutableCookies
Expand All @@ -27,17 +28,31 @@ 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
*/
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)
}

/**
Expand All @@ -52,12 +67,15 @@ export function isRedirectError<U extends string>(
): error is RedirectError<U> {
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')
)
}

Expand Down
7 changes: 5 additions & 2 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
<meta
httpEquiv="refresh"
content={`0;url=${redirectUrl}`}
content={`${isPermanent ? 0 : 1};url=${redirectUrl}`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I understand setting this number to 1 means the browser will wait 1 second before redirecting, are you aware of that?

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. Googlebot considers 0 to be permanent and anything above 0 to be temporary: https://developers.google.com/search/docs/crawling-indexing/301-redirects

It's an SEO thing basically, unfortunately Googlebot doesn't support http-equiv status which would allow us to do this while preserving instant redirect.

Copy link
Contributor

@karlhorky karlhorky Aug 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1s delay downsides with redirect() (maybe this should be its own issue/discussion)

Hm, the 1s delay on redirect() is not ideal for auth gating (temporary redirect() in either in page.tsx or in layout.tsx):

  • unusual, slow UX (waiting for 1 second)
  • switching to permanentRedirect() would not be correct semantically (a permanent redirect from a page to /login not correct)
  • often Middleware is suggested here, but Middleware cannot be used with database auth due to Middleware lacking support for database TCP connections

Ideally, one of the following seems like more well-rounded solutions without the UX downsides:

A) Middleware TCP support
B) Setting a Location response header when redirect() or permanentRedirect() are used - in a style similar to how React Float changes other parts of tree / response

But I guess for now we will consider recommending permanentRedirect() to our students for now to avoid the 1s weirdness (tradeoff against the semantically-incorrect "permanent" part)

If a <meta> tag is the only thing that can be used for now, I guess the previous solution of 307 + 0s refresh feels at first glance like a better tradeoff... (at least the status code is correct in that case and it doesn't promote a slow UX)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A simple solution would be to redirect from JavaScript if there is the refresh meta tag so you don’t need to wait the 1 second delay, not sure if the router already does this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tricky part is around streaming in general. If it's used in a streaming context and HTTP headers are already sent to the client (e.g., with status 200), we can't emit additional headers afterward. This is why meta tag is used this way as far as I understand.

Google warns Javascript redirect to be the last resort to adopt so I guess it's safe to avoid that:
https://developers.google.com/search/docs/crawling-indexing/301-redirects#jslocation

Copy link
Contributor

@karlhorky karlhorky Aug 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's used in a streaming context and HTTP headers are already sent to the client (e.g., with status 200), we can't emit additional headers afterward

Right, with my suggestion B above I meant to "lift up" the redirect() / permanentRedirect() and logic to an earlier part in the response cycle, so that both the status code and the Location header could be set

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The moment your components run you can't assume the headers haven't been sent yet. In the near future there will be early hints / preloads that means even before your code runs it's already streaming.

Redirecting for authentication should be handled in middleware if you want it to run in front of any rendering.

Copy link
Contributor

@karlhorky karlhorky Aug 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use pages / layouts auth gating as also recommended as an alternative by Lee - not as a choice, but as a necessity.

We do this because Middleware is not an option. The Edge Runtime Middleware is not capable of doing auth gating with database auth, if you need a TCP connection, which most database options do (workaround edge database products exist, such as Vercel Postgres / Neon, but not everyone can use them). So these users of Next.js App Router are currently stuck with pages / layout auth gating.

However, I would love to be able to do this with Middleware, eg. if there will be tangible movement soon about Node.js Runtime Middleware as mentioned by Lee:

exploring ways to bridge the gap towards allowing folks to experimentally/"dangerously" opt-into using the Node.js Runtime

key={error.digest}
/>
)
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { permanentRedirect } from 'next/navigation'

export default function Page() {
permanentRedirect('/redirect/result')
return <></>
}
41 changes: 41 additions & 0 deletions test/e2e/app-dir/navigation/app/redirect/suspense-2/page.js
Original file line number Diff line number Diff line change
@@ -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>
)
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/navigation/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})

Expand Down Expand Up @@ -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(
'<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(
'<meta http-equiv="refresh" content="0;url=/redirect/result"/>'
)
Expand Down
Loading