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
+
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 + } + + returnCause 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') + }) })