diff --git a/docs/01-app/03-api-reference/04-functions/forbidden.mdx b/docs/01-app/03-api-reference/04-functions/forbidden.mdx index 3ebd3b5fd9dfb..385d30bf681fb 100644 --- a/docs/01-app/03-api-reference/04-functions/forbidden.mdx +++ b/docs/01-app/03-api-reference/04-functions/forbidden.mdx @@ -67,6 +67,45 @@ export default async function AdminPage() { } ``` +You can also pass an error cause to `forbidden` to handle different types of errors before escalating to the Next.js' error page. For example, you can call `forbidden` with an error cause associated with a session expiration error and redirect the user to the login page. + +```tsx filename="app/lib/dal.ts" +import { forbidden } from 'next/navigation' +import { cookies } from 'next/headers' + +export async function verifySession() { + const cookieStore = await cookies() + const token = cookieStore.get('token') + + if (!token) { + forbidden(new AuthError('No token found')) + } + + if (isExpired(token.value)) { + forbidden(new SessionExpiredError('Session expired')) + } +} +``` + +```tsx filename="app/auth/page.tsx" +import { InvalidPermissionError, isExpired } from '@/app/lib/dal' + +export default async function AdminPage() { + try { + await verifySession() + } catch (err) { + if (err instanceof Error && err.cause instanceof SessionExpiredError) { + redirect('/login') + } + + throw err + } + + // Render the admin page for authorized users + return <> +} +``` + ## Good to know - The `forbidden` function cannot be called in the [root layout](/docs/app/building-your-application/routing/layouts-and-templates#root-layout-required). diff --git a/docs/01-app/03-api-reference/04-functions/not-found.mdx b/docs/01-app/03-api-reference/04-functions/not-found.mdx index c6827f3f96066..207642106fb26 100644 --- a/docs/01-app/03-api-reference/04-functions/not-found.mdx +++ b/docs/01-app/03-api-reference/04-functions/not-found.mdx @@ -19,7 +19,8 @@ async function fetchUser(id) { } export default async function Profile({ params }) { - const user = await fetchUser(params.id) + const id = await params.id + const user = await fetchUser(id) if (!user) { notFound() @@ -29,6 +30,40 @@ export default async function Profile({ params }) { } ``` +You can also pass an error cause to `notFound` to handle different types of errors before escalating to the Next.js' error page. For example, you can call `notFound` with a path where the resource was relocated to, which can be selectively handled when using this function, while still falling back to a 404 page if unhandled. + +```tsx filename="app/auth/page.js" +import { ResourceRelocatedError } from '@/app/lib/dal' +import { notFound } from 'next/navigation' + +async function fetchArticle(slug) { + const res = await fetch('https://...') + + if (res.status === 301) { + notFound(new ResourceRelocatedError('/new-url')) + } + + return res.json() +} + +export default function Page({ params }) { + let article + + try { + const slug = await params.slug + article = await fetchArticle(slug) + } catch (err) { + if (err.cause instanceof ResourceRelocatedError) { + redirect(err.cause.redirectTo) + } + + throw err + } + + // ... +} +``` + > **Good to know**: `notFound()` does not require you to use `return notFound()` due to using the TypeScript [`never`](https://www.typescriptlang.org/docs/handbook/2/functions.html#never) type. ## Version History diff --git a/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx b/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx index b1f1166525490..976517ff0dfc6 100644 --- a/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx +++ b/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx @@ -16,16 +16,17 @@ If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api ## Parameters -The `permanentRedirect` function accepts two arguments: +The `permanentRedirect` function accepts three arguments: ```js -permanentRedirect(path, type) +permanentRedirect(path, type, cause) ``` -| 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. | +| 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. | +| `cause` | `Error` | An optional error that will be attached to the `cause` property of the error thrown by Next.js. | 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/server-actions-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. diff --git a/docs/01-app/03-api-reference/04-functions/redirect.mdx b/docs/01-app/03-api-reference/04-functions/redirect.mdx index 330a7aed1b215..28a1f53a1cdf4 100644 --- a/docs/01-app/03-api-reference/04-functions/redirect.mdx +++ b/docs/01-app/03-api-reference/04-functions/redirect.mdx @@ -19,16 +19,17 @@ If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api ## Parameters -The `redirect` function accepts two arguments: +The `redirect` function accepts three arguments: ```js redirect(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. | +| 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. | +| `cause` | `Error` | An optional error that will be attached to the `cause` property of the error thrown by Next.js. | By default, `redirect` will use `push` (adding a new entry to the browser history stack) in [Server Actions](/docs/app/building-your-application/data-fetching/server-actions-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. diff --git a/docs/01-app/03-api-reference/04-functions/unauthorized.mdx b/docs/01-app/03-api-reference/04-functions/unauthorized.mdx index 53e63b1afd4ff..8a9914ae4ab0e 100644 --- a/docs/01-app/03-api-reference/04-functions/unauthorized.mdx +++ b/docs/01-app/03-api-reference/04-functions/unauthorized.mdx @@ -75,6 +75,49 @@ export default async function DashboardPage() { } ``` +You can also pass an error cause to `forbidden` to handle different types of errors before escalating to the Next.js' error page. For example, you can call `unauthorized` with an error cause associated with a session expiration error and redirect the user to the login page. + +```tsx filename="app/lib/dal.ts" +import { unauthorized } from 'next/navigation' +import { cookies } from 'next/headers' + +export async function verifySession() { + const cookieStore = await cookies() + const token = cookieStore.get('token') + + if (!token) { + unauthorized(new AuthError('No token found')) + } + + if (isExpired(token.value)) { + unauthorized(new SessionExpiredError('Session expired')) + } +} +``` + +```tsx filename="app/auth/page.tsx" +import { SessionExpiredError, verifySession } from '@/app/lib/dal' + +export default async function AdminPage() { + try { + await verifySession() + } catch (err) { + if (err instanceof Error && err.cause instanceof SessionExpiredError) { + redirect('/login') + } + + throw err + } + + // Render the dashboard for authenticated users + return ( +
+

Welcome to the Dashboard

+
+ ) +} +``` + ## Good to know - The `unauthorized` function cannot be called in the [root layout](/docs/app/building-your-application/routing/layouts-and-templates#root-layout-required). diff --git a/packages/next/src/client/components/forbidden.ts b/packages/next/src/client/components/forbidden.ts index 4e52b4fc3aacd..bbb709f0f645e 100644 --- a/packages/next/src/client/components/forbidden.ts +++ b/packages/next/src/client/components/forbidden.ts @@ -19,7 +19,7 @@ import { const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};403` -export function forbidden(): never { +export function forbidden(cause?: Error): never { if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) { throw new Error( `\`forbidden()\` is experimental and only allowed to be enabled when \`experimental.authInterrupts\` is enabled.` @@ -27,7 +27,7 @@ export function forbidden(): never { } // eslint-disable-next-line no-throw-literal - const error = new Error(DIGEST) as HTTPAccessFallbackError + const error = new Error(DIGEST, { cause }) as HTTPAccessFallbackError ;(error as HTTPAccessFallbackError).digest = DIGEST throw error } diff --git a/packages/next/src/client/components/not-found.ts b/packages/next/src/client/components/not-found.ts index 7c49aed87456e..7fd56d2cf2b88 100644 --- a/packages/next/src/client/components/not-found.ts +++ b/packages/next/src/client/components/not-found.ts @@ -20,9 +20,9 @@ import { const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};404` -export function notFound(): never { +export function notFound(cause?: Error): never { // eslint-disable-next-line no-throw-literal - const error = new Error(DIGEST) as HTTPAccessFallbackError + const error = new Error(DIGEST, { cause }) as HTTPAccessFallbackError ;(error as HTTPAccessFallbackError).digest = DIGEST throw error diff --git a/packages/next/src/client/components/redirect.ts b/packages/next/src/client/components/redirect.ts index 4c1464143e5a3..c35bcbc2494bc 100644 --- a/packages/next/src/client/components/redirect.ts +++ b/packages/next/src/client/components/redirect.ts @@ -10,9 +10,10 @@ import { export function getRedirectError( url: string, type: RedirectType, - statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect + statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect, + cause?: Error ): RedirectError { - const error = new Error(REDIRECT_ERROR_CODE) as RedirectError + const error = new Error(REDIRECT_ERROR_CODE, { cause }) as RedirectError error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};` return error } @@ -32,7 +33,8 @@ export function getRedirectError( export function redirect( /** The URL to redirect to */ url: string, - type?: RedirectType + type?: RedirectType, + cause?: Error ): never { const actionStore = actionAsyncStorage.getStore() const redirectType = @@ -40,7 +42,8 @@ export function redirect( throw getRedirectError( url, redirectType, - RedirectStatusCode.TemporaryRedirect + RedirectStatusCode.TemporaryRedirect, + cause ) } @@ -58,9 +61,10 @@ export function redirect( export function permanentRedirect( /** The URL to redirect to */ url: string, - type: RedirectType = RedirectType.replace + type: RedirectType = RedirectType.replace, + cause?: Error ): never { - throw getRedirectError(url, type, RedirectStatusCode.PermanentRedirect) + throw getRedirectError(url, type, RedirectStatusCode.PermanentRedirect, cause) } /** diff --git a/packages/next/src/client/components/unauthorized.ts b/packages/next/src/client/components/unauthorized.ts index 59c8aebf55cb3..9b2033bd88962 100644 --- a/packages/next/src/client/components/unauthorized.ts +++ b/packages/next/src/client/components/unauthorized.ts @@ -20,7 +20,7 @@ import { const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};401` -export function unauthorized(): never { +export function unauthorized(cause?: Error): never { if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) { throw new Error( `\`unauthorized()\` is experimental and only allowed to be used when \`experimental.authInterrupts\` is enabled.` @@ -28,7 +28,7 @@ export function unauthorized(): never { } // eslint-disable-next-line no-throw-literal - const error = new Error(DIGEST) as HTTPAccessFallbackError + const error = new Error(DIGEST, { cause }) as HTTPAccessFallbackError ;(error as HTTPAccessFallbackError).digest = DIGEST throw error } diff --git a/test/e2e/app-dir/forbidden/basic/app/cause/page.js b/test/e2e/app-dir/forbidden/basic/app/cause/page.js new file mode 100644 index 0000000000000..20b3aa55b2fb9 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/cause/page.js @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation' +import { SessionExpiredError, verifyAuth } from './utils' + +export default async function Page() { + try { + await verifyAuth() + } catch (err) { + if (err.cause instanceof SessionExpiredError) { + redirect('/') + } + + throw err + } + + return

Cause Page

+} diff --git a/test/e2e/app-dir/forbidden/basic/app/cause/utils.js b/test/e2e/app-dir/forbidden/basic/app/cause/utils.js new file mode 100644 index 0000000000000..c292009aac054 --- /dev/null +++ b/test/e2e/app-dir/forbidden/basic/app/cause/utils.js @@ -0,0 +1,29 @@ +import { cookies } from 'next/headers' +import { forbidden } from 'next/navigation' + +export class AuthError extends Error { + constructor(message) { + super(message) + this.name = 'AuthError' + } +} + +export class SessionExpiredError extends Error { + constructor(message) { + super(message) + this.name = 'AuthError' + } +} + +export async function verifyAuth() { + const cookieStore = await cookies() + const token = cookieStore.get('token') + + if (!token) { + forbidden(new AuthError('No token found')) + } + + if (token.value === 'expired') { + forbidden(new SessionExpiredError('Session expired')) + } +} diff --git a/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts b/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts index a92c1ebb0a571..1577b6da3c297 100644 --- a/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts +++ b/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts @@ -1,4 +1,5 @@ import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' describe('app dir - forbidden with customized boundary', () => { const { next } = nextTestSetup({ @@ -38,4 +39,22 @@ describe('app dir - forbidden with customized boundary', () => { 'Root Forbidden' ) }) + + it('should support a custom error cause', async () => { + const browser = await next.browser('/cause') + + // we don't have explicit handling for a missing token, but we still triggered `forbidden` + expect(await browser.elementByCss('h1').text()).toBe('Root Forbidden') + + // Set a token + await browser.eval("document.cookie='token=expired'") + await browser.refresh() + + // expired token, and the user decided that SessionExpiredError should be handled by redirecting to root + retry(async () => { + expect(await browser.url()).toBe(next.url) + }) + + expect(await browser.elementByCss('h1').text()).toBe('My page') + }) }) diff --git a/test/e2e/app-dir/unauthorized/basic/app/cause/page.js b/test/e2e/app-dir/unauthorized/basic/app/cause/page.js new file mode 100644 index 0000000000000..20b3aa55b2fb9 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/cause/page.js @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation' +import { SessionExpiredError, verifyAuth } from './utils' + +export default async function Page() { + try { + await verifyAuth() + } catch (err) { + if (err.cause instanceof SessionExpiredError) { + redirect('/') + } + + throw err + } + + return

Cause Page

+} diff --git a/test/e2e/app-dir/unauthorized/basic/app/cause/utils.js b/test/e2e/app-dir/unauthorized/basic/app/cause/utils.js new file mode 100644 index 0000000000000..1c86ef45554d7 --- /dev/null +++ b/test/e2e/app-dir/unauthorized/basic/app/cause/utils.js @@ -0,0 +1,29 @@ +import { cookies } from 'next/headers' +import { unauthorized } from 'next/navigation' + +export class AuthError extends Error { + constructor(message) { + super(message) + this.name = 'AuthError' + } +} + +export class SessionExpiredError extends Error { + constructor(message) { + super(message) + this.name = 'AuthError' + } +} + +export async function verifyAuth() { + const cookieStore = await cookies() + const token = cookieStore.get('token') + + if (!token) { + unauthorized(new AuthError('No token found')) + } + + if (token.value === 'expired') { + unauthorized(new SessionExpiredError('Session expired')) + } +} diff --git a/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts b/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts index f71a60b0c03a2..11a2e1a5c75b3 100644 --- a/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts +++ b/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts @@ -1,4 +1,5 @@ import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' describe('app dir - unauthorized - basic', () => { const { next } = nextTestSetup({ @@ -38,4 +39,22 @@ describe('app dir - unauthorized - basic', () => { 'Root Unauthorized' ) }) + + it('should support a custom error cause', async () => { + const browser = await next.browser('/cause') + + // we don't have explicit handling for a missing token, but we still triggered `unauthorized` + expect(await browser.elementByCss('h1').text()).toBe('Root Unauthorized') + + // Set a token + await browser.eval("document.cookie='token=expired'") + await browser.refresh() + + // expired token, and the user decided that SessionExpiredError should be handled by redirecting to root + retry(async () => { + expect(await browser.url()).toBe(next.url) + }) + + expect(await browser.elementByCss('h1').text()).toBe('My page') + }) })