From dfa6fcbb6d7c2fb54ed506ab73b6896a9fa7ab8f Mon Sep 17 00:00:00 2001 From: Sam Sperling Date: Wed, 30 Apr 2025 22:35:19 +1000 Subject: [PATCH 1/6] Adding 410 Gone functionality --- .../09-handling-removed-content.mdx | 182 ++++++++++++++++++ .../02-file-conventions/gone.mdx | 114 +++++++++++ .../03-file-conventions/error.mdx | 1 + .../03-file-conventions/gone.mdx | 114 +++++++++++ .../03-file-conventions/not-found.mdx | 5 + .../04-api-reference/04-functions/gone.mdx | 90 +++++++++ .../03-functions/get-server-side-props.mdx | 3 +- .../03-functions/get-static-props.mdx | 26 +++ errors/gssp-mixed-gone-not-found.mdx | 49 +++++ errors/gssp-mixed-gone-redirect.mdx | 55 ++++++ .../webpack/loaders/next-app-loader/index.ts | 2 + .../next/src/client/components/gone-error.tsx | 10 + packages/next/src/client/components/gone.ts | 29 +++ .../http-access-fallback/error-boundary.tsx | 12 +- .../http-access-fallback.ts | 5 +- .../components/navigation.react-server.ts | 1 + .../next/src/lib/metadata/resolve-metadata.ts | 7 +- packages/next/src/server/base-server.ts | 50 +++-- .../next/src/server/lib/app-dir-module.ts | 2 +- packages/next/src/server/next-server.ts | 16 +- packages/next/src/server/next.ts | 14 +- packages/next/src/server/render-result.ts | 1 + packages/next/src/server/render.tsx | 40 +++- 23 files changed, 801 insertions(+), 27 deletions(-) create mode 100644 docs/01-app/03-building-your-application/01-routing/09-handling-removed-content.mdx create mode 100644 docs/01-app/04-api-reference/02-file-conventions/gone.mdx create mode 100644 docs/01-app/04-api-reference/03-file-conventions/gone.mdx create mode 100644 docs/01-app/04-api-reference/04-functions/gone.mdx create mode 100644 errors/gssp-mixed-gone-not-found.mdx create mode 100644 errors/gssp-mixed-gone-redirect.mdx create mode 100644 packages/next/src/client/components/gone-error.tsx create mode 100644 packages/next/src/client/components/gone.ts diff --git a/docs/01-app/03-building-your-application/01-routing/09-handling-removed-content.mdx b/docs/01-app/03-building-your-application/01-routing/09-handling-removed-content.mdx new file mode 100644 index 0000000000000..085ca4a10fce0 --- /dev/null +++ b/docs/01-app/03-building-your-application/01-routing/09-handling-removed-content.mdx @@ -0,0 +1,182 @@ +--- +title: Handling Removed Content +description: Learn how to properly handle content that has been permanently removed from your Next.js application. +--- + +# Handling Removed Content + +There are situations where content that was once available should be marked as "permanently removed" rather than "not found." HTTP provides two different status codes for these cases: + +- **404 Not Found**: The resource could not be found but might be available in the future +- **410 Gone**: The resource has been permanently removed and will not be available again + +This guide explains how to implement and use the 410 Gone status in your Next.js application, which can improve your site's UX and SEO by clearly communicating when content has been permanently removed. + +## When to Use 410 Gone vs 404 Not Found + +Use a **410 Gone** status when: + +- Content has been deliberately and permanently removed +- You want search engines to remove the content from their indices faster +- You want to communicate to users that the content will not return + +Use a **404 Not Found** status when: + +- Content simply can't be found but might exist in the future +- A user has entered an incorrect URL +- Content is temporarily unavailable + +## App Router Implementation + +### Using the `gone()` Function in Server Components + +In React Server Components, you can use the `gone()` function to trigger a 410 Gone response: + +```tsx filename="app/posts/[slug]/page.tsx" +import { gone } from 'next/navigation' + +async function getPost(slug: string) { + const res = await fetch(`https://api.example.com/posts/${slug}`) + if (res.status === 410) return { isDeleted: true } + if (!res.ok) return null + return res.json() +} + +export default async function Post({ params }: { params: { slug: string } }) { + const post = await getPost(params.slug) + + // For content that doesn't exist + if (!post) { + notFound() + } + + // For content that has been deliberately removed + if (post.isDeleted) { + gone() + } + + return ( +
+

{post.title}

+
+
+ ) +} +``` + +### Creating a Custom 410 Page with `gone.js` + +You can create custom UI for 410 responses by adding a `gone.js` file to the appropriate route segment: + +```tsx filename="app/posts/gone.js" +export default function PostGone() { + return ( +
+

Content Permanently Removed

+

This post has been permanently removed and is no longer available.

+

+ You might be interested in our other posts. +

+
+ ) +} +``` + +The closest `gone.js` file to the route where `gone()` was called will be used to render the UI. + +## Pages Router Implementation + +### Using 410 in Data Fetching Methods + +For the Pages Router, you can return `{ gone: true }` from data fetching methods to trigger a 410 Gone response: + +```tsx filename="pages/posts/[slug].tsx" +export async function getServerSideProps({ params }) { + const res = await fetch(`https://api.example.com/posts/${params.slug}`) + + if (res.status === 410) { + // Return gone: true to indicate the content has been permanently removed + return { + gone: true, + } + } + + if (!res.ok) { + // Return notFound for content that couldn't be found + return { + notFound: true, + } + } + + const post = await res.json() + + return { + props: { + post, + }, + } +} + +export default function Post({ post }) { + return ( +
+

{post.title}

+
+
+ ) +} +``` + +### Creating a Custom 410 Page + +For the Pages Router, you can create a custom 410 page by adding a `410.js` file to your `pages` directory: + +```tsx filename="pages/410.js" +export default function Custom410() { + return ( +
+

410 - Content Permanently Removed

+

+ The content you are looking for has been permanently removed and is no + longer available. +

+

+ Return to home page +

+
+ ) +} +``` + +## SEO Benefits of Using 410 Gone + +Using the 410 status code provides several SEO benefits: + +1. **Faster removal from search indices**: Search engines like Google treat 410 responses as a strong signal that the content should be removed from their indices more quickly than with a 404. + +2. **Clear communication**: It clearly indicates that the removal was intentional rather than a temporary error or missing content. + +3. **User experience**: It allows you to create specific messaging for users looking for content that has been deliberately removed. + +All `gone.js` components and pages with 410 responses automatically include a `` tag to ensure search engines don't index these pages. + +## Best Practices + +1. **Use judiciously**: Only use 410 for content that has been deliberately removed and won't return. + +2. **Provide alternatives**: When showing a 410 page, offer users alternative content when possible. + +3. **Maintain consistency**: Be consistent in your approach to removed content across your application. + +4. **Monitor 410 responses**: Keep track of URLs returning 410 responses to identify patterns or issues. + +5. **Consider redirection**: For high-traffic pages that have been removed, consider redirecting to related content instead of showing a 410 page. + +## Common Use Cases + +- Discontinued products in e-commerce +- Deleted blog posts or articles +- Removed user profiles +- Content that violates terms of service +- Content removed for legal reasons +- Expired time-limited content (contests, promotions) diff --git a/docs/01-app/04-api-reference/02-file-conventions/gone.mdx b/docs/01-app/04-api-reference/02-file-conventions/gone.mdx new file mode 100644 index 0000000000000..8c711d4293900 --- /dev/null +++ b/docs/01-app/04-api-reference/02-file-conventions/gone.mdx @@ -0,0 +1,114 @@ +--- +title: gone.js +description: API reference for the gone.js file convention. +--- + +A `gone.js` file is used to render UI when the [`gone()`](/docs/app/api-reference/functions/gone) function is thrown within a route segment. + +```tsx filename="app/posts/[slug]/gone.js" +export default function PostGone() { + return ( +
+

Post Removed

+

+ This content has been permanently removed and is no longer available. +

+
+ ) +} +``` + +## Props + +`gone.js` components do not receive any props. + +## Returns + +`gone.js` components should return valid JSX. + +## Behavior + +### Status Code + +When the `gone()` function is thrown in a route segment, the HTTP status code is set to `410 Gone`. + +### File Conventions + +- A `gone.js` file will handle all 410 errors in the current segment and any nested segments below it. +- The closest `gone.js` file will be used. +- If no `gone.js` file is found, the runtime will use the default `gone` UI. +- You can use a `gone.js` file at the root of your `app` directory to create a custom 410 page for your entire application. + +### Nesting + +You can use `gone.js` with route segments to create custom "gone" UIs for specific parts of your application: + +``` +app/ +├── posts/ +│ ├── [slug]/ +│ │ ├── gone.js # Handles 410 errors for /posts/[slug] +│ │ └── page.js +│ └── gone.js # Handles 410 errors for /posts +├── products/ +│ └── [...slug]/ +│ ├── gone.js # Handles 410 errors for /products/[...slug] +│ └── page.js +└── gone.js # Handles 410 errors for all other routes +``` + +### SEO + +- The `gone.js` component automatically includes a `` tag to prevent search engines from indexing the page. +- The HTTP status code 410 informs search engines that the resource is permanently gone, which helps with faster removal from search indexes compared to a 404 status. + +### Examples + +#### Basic Usage + +```tsx filename="app/posts/[slug]/gone.js" +export default function PostGone() { + return ( +
+

Content Removed

+

This post has been permanently removed from our site.

+

+ You may be interested in our other articles. +

+
+ ) +} +``` + +#### With Layout + +```tsx filename="app/gone.js" +export default function Gone() { + return ( +
+
+

410 - Content Gone

+

This content has been permanently removed.

+ +
+
+ ) +} +``` + +## Pages Router Support + +If you're using the Pages Router, you can create a `pages/410.js` file to create a custom 410 Gone page: + +```tsx filename="pages/410.js" +export default function Custom410() { + return ( +
+

410 - Content Permanently Removed

+

+ The requested content has been permanently removed from this server. +

+
+ ) +} +``` diff --git a/docs/01-app/04-api-reference/03-file-conventions/error.mdx b/docs/01-app/04-api-reference/03-file-conventions/error.mdx index 18685e3d4ce75..c98aae1aa78c4 100644 --- a/docs/01-app/04-api-reference/03-file-conventions/error.mdx +++ b/docs/01-app/04-api-reference/03-file-conventions/error.mdx @@ -5,6 +5,7 @@ related: title: Learn more about error handling links: - app/building-your-application/routing/error-handling + - app/building-your-application/routing/handling-removed-content --- An **error** file allows you to handle unexpected runtime errors and display fallback UI. diff --git a/docs/01-app/04-api-reference/03-file-conventions/gone.mdx b/docs/01-app/04-api-reference/03-file-conventions/gone.mdx new file mode 100644 index 0000000000000..8c711d4293900 --- /dev/null +++ b/docs/01-app/04-api-reference/03-file-conventions/gone.mdx @@ -0,0 +1,114 @@ +--- +title: gone.js +description: API reference for the gone.js file convention. +--- + +A `gone.js` file is used to render UI when the [`gone()`](/docs/app/api-reference/functions/gone) function is thrown within a route segment. + +```tsx filename="app/posts/[slug]/gone.js" +export default function PostGone() { + return ( +
+

Post Removed

+

+ This content has been permanently removed and is no longer available. +

+
+ ) +} +``` + +## Props + +`gone.js` components do not receive any props. + +## Returns + +`gone.js` components should return valid JSX. + +## Behavior + +### Status Code + +When the `gone()` function is thrown in a route segment, the HTTP status code is set to `410 Gone`. + +### File Conventions + +- A `gone.js` file will handle all 410 errors in the current segment and any nested segments below it. +- The closest `gone.js` file will be used. +- If no `gone.js` file is found, the runtime will use the default `gone` UI. +- You can use a `gone.js` file at the root of your `app` directory to create a custom 410 page for your entire application. + +### Nesting + +You can use `gone.js` with route segments to create custom "gone" UIs for specific parts of your application: + +``` +app/ +├── posts/ +│ ├── [slug]/ +│ │ ├── gone.js # Handles 410 errors for /posts/[slug] +│ │ └── page.js +│ └── gone.js # Handles 410 errors for /posts +├── products/ +│ └── [...slug]/ +│ ├── gone.js # Handles 410 errors for /products/[...slug] +│ └── page.js +└── gone.js # Handles 410 errors for all other routes +``` + +### SEO + +- The `gone.js` component automatically includes a `` tag to prevent search engines from indexing the page. +- The HTTP status code 410 informs search engines that the resource is permanently gone, which helps with faster removal from search indexes compared to a 404 status. + +### Examples + +#### Basic Usage + +```tsx filename="app/posts/[slug]/gone.js" +export default function PostGone() { + return ( +
+

Content Removed

+

This post has been permanently removed from our site.

+

+ You may be interested in our other articles. +

+
+ ) +} +``` + +#### With Layout + +```tsx filename="app/gone.js" +export default function Gone() { + return ( +
+
+

410 - Content Gone

+

This content has been permanently removed.

+ +
+
+ ) +} +``` + +## Pages Router Support + +If you're using the Pages Router, you can create a `pages/410.js` file to create a custom 410 Gone page: + +```tsx filename="pages/410.js" +export default function Custom410() { + return ( +
+

410 - Content Permanently Removed

+

+ The requested content has been permanently removed from this server. +

+
+ ) +} +``` diff --git a/docs/01-app/04-api-reference/03-file-conventions/not-found.mdx b/docs/01-app/04-api-reference/03-file-conventions/not-found.mdx index a5b2aae9db1a6..a73a60e54403b 100644 --- a/docs/01-app/04-api-reference/03-file-conventions/not-found.mdx +++ b/docs/01-app/04-api-reference/03-file-conventions/not-found.mdx @@ -1,6 +1,11 @@ --- title: not-found.js description: API reference for the not-found.js file. +related: + title: Learn more about error handling + links: + - app/building-your-application/routing/error-handling + - app/building-your-application/routing/handling-removed-content --- The **not-found** file is used to render UI when the [`notFound`](/docs/app/api-reference/functions/not-found) function is thrown within a route segment. Along with serving a custom UI, Next.js will return a `200` HTTP status code for streamed responses, and `404` for non-streamed responses. diff --git a/docs/01-app/04-api-reference/04-functions/gone.mdx b/docs/01-app/04-api-reference/04-functions/gone.mdx new file mode 100644 index 0000000000000..b4d26db4f246f --- /dev/null +++ b/docs/01-app/04-api-reference/04-functions/gone.mdx @@ -0,0 +1,90 @@ +--- +title: gone +description: API Reference for the gone function. +--- + +The `gone()` function allows you to render the [`gone.js` file](/docs/app/api-reference/file-conventions/gone) within a route segment as well as inject a `` tag. + +## `gone()` + +Invoking the `gone()` function throws a `NEXT_HTTP_ERROR_FALLBACK` error and terminates rendering of the route segment in which it was thrown. Specifying a [**gone** file](/docs/app/api-reference/file-conventions/gone) allows you to gracefully handle such errors by rendering a Gone UI within the segment. + +```jsx filename="app/post/[slug]/page.js" +import { gone } from 'next/navigation' + +async function fetchPost(slug) { + const res = await fetch('https://...') + if (res.status === 410) return undefined + return res.json() +} + +export default async function Post({ params }) { + const post = await fetchPost(params.slug) + + if (!post) { + gone() + } + + // Continue rendering if post exists... + return +} +``` + +### When to Use `gone()` + +The HTTP 410 status code indicates that the resource once existed but has been permanently removed and will not be available again. This differs from a 404 status code which indicates that a resource could not be found but might appear later. + +Use `gone()` when: + +- A resource has been deliberately removed and won't be coming back +- You want to clearly communicate to users and search engines that the content is permanently gone +- You want to maintain your SEO by properly distinguishing between nonexistent resources (404) and deliberately removed ones (410) + +### Returns + +`gone()` does not return anything as it throws an error to stop the rendering of the route segment. + +### Examples + +#### Handling Deleted Content + +```jsx filename="app/products/[id]/page.js" +import { gone } from 'next/navigation' +import { getProduct, isProductDeleted } from '@/lib/products' + +export default async function Product({ params }) { + // Check if product was explicitly deleted + if (await isProductDeleted(params.id)) { + // Respond with a 410 Gone status + gone() + } + + const product = await getProduct(params.id) + + // Handle product not found (404 case) + if (!product) { + notFound() + } + + return +} +``` + +#### Using with a Custom Gone UI + +You can create a custom UI for 410 responses by creating a `gone.js` file in the appropriate route segment: + +```jsx filename="app/products/gone.js" +export default function ProductGone() { + return ( +
+

Product Removed

+

This product has been permanently removed from our catalog.

+

+ You may want to browse our current products{' '} + instead. +

+
+ ) +} +``` diff --git a/docs/02-pages/03-api-reference/03-functions/get-server-side-props.mdx b/docs/02-pages/03-api-reference/03-functions/get-server-side-props.mdx index c88811863e112..d852913b38bd1 100644 --- a/docs/02-pages/03-api-reference/03-functions/get-server-side-props.mdx +++ b/docs/02-pages/03-api-reference/03-functions/get-server-side-props.mdx @@ -88,7 +88,7 @@ export async function getServerSideProps(context) { ### `notFound` -The `notFound` boolean allows the page to return a `404` status and [404 Page](/docs/pages/building-your-application/routing/custom-error#404-page). With `notFound: true`, the page will return a `404` even if there was a successfully generated page before. This is meant to support use cases like user-generated content getting removed by its author. +The `notFound` boolean allows the page to return a `404` status and [404 Page](/docs/pages/building-your-application/routing/custom-error#404-page). With `notFound: true`, the page will return a `404` even if there was a successfully generated page before. This is meant to support use cases like user-generated content that can't be found. ```js export async function getServerSideProps(context) { @@ -135,6 +135,7 @@ export async function getServerSideProps(context) { | Version | Changes | | --------- | ----------------------------------------------------------------------------------------------------------- | +| `v15.0.0` | `gone` option added. | | `v13.4.0` | [App Router](/docs/app/building-your-application/data-fetching) is now stable with simplified data fetching | | `v10.0.0` | `locale`, `locales`, `defaultLocale`, and `notFound` options added. | | `v9.3.0` | `getServerSideProps` introduced. | diff --git a/docs/02-pages/03-api-reference/03-functions/get-static-props.mdx b/docs/02-pages/03-api-reference/03-functions/get-static-props.mdx index 5f0a40d6e6966..8f3ea6d426cf7 100644 --- a/docs/02-pages/03-api-reference/03-functions/get-static-props.mdx +++ b/docs/02-pages/03-api-reference/03-functions/get-static-props.mdx @@ -128,6 +128,31 @@ export async function getStaticProps(context) { > **Good to know**: `notFound` is not needed for [`fallback: false`](/docs/pages/api-reference/functions/get-static-paths#fallback-false) mode as only paths returned from `getStaticPaths` will be pre-rendered. +### `gone` + +The `gone` boolean allows the page to return a `410 Gone` status and [410 Page](/docs/pages/building-your-application/routing/custom-error#410-page). With `gone: true`, the page will return a `410` even if there was a successfully generated page before. This is meant to support use cases like user-generated content that has been deliberately removed by its author. Note, `gone` follows the same `revalidate` behavior [described here](#revalidate). + +While a 404 status indicates that a resource could not be found but might be available in the future, a 410 status indicates that the resource has been permanently removed and will not be available again. This is particularly useful for SEO and helps search engines to remove the content from their indices faster. + +```js +export async function getStaticProps(context) { + const res = await fetch(`https://.../data`) + const data = await res.json() + + if (data?.isDeleted) { + return { + gone: true, + } + } + + return { + props: { data }, // will be passed to the page component as props + } +} +``` + +> **Good to know**: Like `notFound`, `gone` is not needed for [`fallback: false`](/docs/pages/api-reference/functions/get-static-paths#fallback-false) mode as only paths returned from `getStaticPaths` will be pre-rendered. + ### `redirect` The `redirect` object allows redirecting to internal or external resources. It should match the shape of `{ destination: string, permanent: boolean }`. @@ -220,6 +245,7 @@ export default Blog | Version | Changes | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `v15.0.0` | `gone` option added. | | `v13.4.0` | [App Router](/docs/app/building-your-application/data-fetching) is now stable with simplified data fetching | | `v12.2.0` | [On-Demand Incremental Static Regeneration](/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#on-demand-revalidation-with-revalidatepath) is stable. | | `v12.1.0` | [On-Demand Incremental Static Regeneration](/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#on-demand-revalidation-with-revalidatepath) added (beta). | diff --git a/errors/gssp-mixed-gone-not-found.mdx b/errors/gssp-mixed-gone-not-found.mdx new file mode 100644 index 0000000000000..e24bc5ef31ec9 --- /dev/null +++ b/errors/gssp-mixed-gone-not-found.mdx @@ -0,0 +1,49 @@ +--- +title: '`notFound` and `gone` cannot both be returned' +--- + +# `notFound` and `gone` cannot both be returned + +## Why This Error Occurred + +In a page's `getStaticProps` or `getServerSideProps` function, you attempted to return both the `notFound` property and the `gone` property. These properties are mutually exclusive—you can only return one of them at a time. + +```js +// This will cause an error +export async function getServerSideProps() { + return { + notFound: true, + gone: true, + } +} +``` + +## Possible Ways to Fix It + +Decide whether you want to show a 404 Not Found error page (`notFound`) or a 410 Gone error page (`gone`): + +### If the content cannot be found but might exist in the future: + +```js +export async function getServerSideProps() { + return { + notFound: true, + } +} +``` + +### If the content has been deliberately and permanently removed: + +```js +export async function getServerSideProps() { + return { + gone: true, + } +} +``` + +Remember that: + +- `notFound` (404) is used when content cannot be found but might exist in the future +- `gone` (410) is used when content has been permanently removed and will not be available again +- You cannot use both at the same time diff --git a/errors/gssp-mixed-gone-redirect.mdx b/errors/gssp-mixed-gone-redirect.mdx new file mode 100644 index 0000000000000..3506ff2b6bfae --- /dev/null +++ b/errors/gssp-mixed-gone-redirect.mdx @@ -0,0 +1,55 @@ +--- +title: '`redirect` and `gone` cannot both be returned' +--- + +# `redirect` and `gone` cannot both be returned + +## Why This Error Occurred + +In a page's `getStaticProps` or `getServerSideProps` function, you attempted to return both the `redirect` property and the `gone` property. These properties are mutually exclusive—you can only return one of them at a time. + +```js +// This will cause an error +export async function getServerSideProps() { + return { + redirect: { + destination: '/', + permanent: false, + }, + gone: true, + } +} +``` + +## Possible Ways to Fix It + +Decide whether you want to redirect the user to a different page (`redirect`) or show a 410 Gone error page (`gone`): + +### If you want to redirect: + +```js +export async function getServerSideProps() { + return { + redirect: { + destination: '/', + permanent: false, + }, + } +} +``` + +### If you want to show a 410 Gone error page: + +```js +export async function getServerSideProps() { + return { + gone: true, + } +} +``` + +Remember that: + +- `redirect` is used to send users to a different page +- `gone` is used to show a 410 Gone error page for content that has been permanently removed +- You cannot use both at the same time diff --git a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts index 8b72ea4cc8e2b..e23783ef7fea3 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts @@ -57,11 +57,13 @@ const HTTP_ACCESS_FALLBACKS = { 'not-found': 'not-found', forbidden: 'forbidden', unauthorized: 'unauthorized', + gone: 'gone', } as const const defaultHTTPAccessFallbackPaths = { 'not-found': 'next/dist/client/components/not-found-error', forbidden: 'next/dist/client/components/forbidden-error', unauthorized: 'next/dist/client/components/unauthorized-error', + gone: 'next/dist/client/components/gone-error', } as const const FILE_TYPES = { diff --git a/packages/next/src/client/components/gone-error.tsx b/packages/next/src/client/components/gone-error.tsx new file mode 100644 index 0000000000000..014bb23f99dc3 --- /dev/null +++ b/packages/next/src/client/components/gone-error.tsx @@ -0,0 +1,10 @@ +import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback' + +export default function Gone() { + return ( + + ) +} diff --git a/packages/next/src/client/components/gone.ts b/packages/next/src/client/components/gone.ts new file mode 100644 index 0000000000000..33c21808fb095 --- /dev/null +++ b/packages/next/src/client/components/gone.ts @@ -0,0 +1,29 @@ +import { + HTTP_ERROR_FALLBACK_ERROR_CODE, + type HTTPAccessFallbackError, +} from './http-access-fallback/http-access-fallback' + +/** + * This function allows you to render the [gone.js file](https://nextjs.org/docs/app/api-reference/file-conventions/gone) + * within a route segment as well as inject a tag. + * + * `gone()` can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * - In a Server Component, this will insert a `` meta tag and set the status code to 410. + * - In a Route Handler or Server Action, it will serve a 410 to the caller. + * + * Read more: [Next.js Docs: `gone`](https://nextjs.org/docs/app/api-reference/functions/gone) + */ + +const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};410` + +export function gone(): never { + // eslint-disable-next-line no-throw-literal + const error = new Error(DIGEST) as HTTPAccessFallbackError + ;(error as HTTPAccessFallbackError).digest = DIGEST + + throw error +} diff --git a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx index 3a8046ffccef4..9f316aab060e4 100644 --- a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx +++ b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx @@ -26,6 +26,7 @@ interface HTTPAccessFallbackBoundaryProps { notFound?: React.ReactNode forbidden?: React.ReactNode unauthorized?: React.ReactNode + gone?: React.ReactNode children: React.ReactNode missingSlots?: Set } @@ -108,14 +109,14 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< previousPathname: props.pathname, } } - render() { - const { notFound, forbidden, unauthorized, children } = this.props + const { notFound, forbidden, unauthorized, gone, children } = this.props const { triggeredStatus } = this.state const errorComponents = { [HTTPAccessErrorStatus.NOT_FOUND]: notFound, [HTTPAccessErrorStatus.FORBIDDEN]: forbidden, [HTTPAccessErrorStatus.UNAUTHORIZED]: unauthorized, + [HTTPAccessErrorStatus.GONE]: gone, } if (triggeredStatus) { @@ -125,9 +126,10 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< triggeredStatus === HTTPAccessErrorStatus.FORBIDDEN && forbidden const isUnauthorized = triggeredStatus === HTTPAccessErrorStatus.UNAUTHORIZED && unauthorized + const isGone = triggeredStatus === HTTPAccessErrorStatus.GONE && gone // If there's no matched boundary in this layer, keep throwing the error by rendering the children - if (!(isNotFound || isForbidden || isUnauthorized)) { + if (!(isNotFound || isForbidden || isUnauthorized || isGone)) { return children } @@ -153,6 +155,7 @@ export function HTTPAccessFallbackBoundary({ notFound, forbidden, unauthorized, + gone, children, }: HTTPAccessFallbackBoundaryProps) { // When we're rendering the missing params shell, this will return null. This @@ -161,7 +164,7 @@ export function HTTPAccessFallbackBoundary({ // (where these error can occur), we will get the correct pathname. const pathname = useUntrackedPathname() const missingSlots = useContext(MissingSlotContext) - const hasErrorFallback = !!(notFound || forbidden || unauthorized) + const hasErrorFallback = !!(notFound || forbidden || unauthorized || gone) if (hasErrorFallback) { return ( @@ -170,6 +173,7 @@ export function HTTPAccessFallbackBoundary({ notFound={notFound} forbidden={forbidden} unauthorized={unauthorized} + gone={gone} missingSlots={missingSlots} > {children} diff --git a/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts b/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts index 87b8366cab1f0..d25cd13adf2c4 100644 --- a/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts +++ b/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts @@ -2,6 +2,7 @@ export const HTTPAccessErrorStatus = { NOT_FOUND: 404, FORBIDDEN: 403, UNAUTHORIZED: 401, + GONE: 410, } const ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus)) @@ -47,7 +48,7 @@ export function getAccessFallbackHTTPStatus( export function getAccessFallbackErrorTypeByStatus( status: number -): 'not-found' | 'forbidden' | 'unauthorized' | undefined { +): 'not-found' | 'forbidden' | 'unauthorized' | 'gone' | undefined { switch (status) { case 401: return 'unauthorized' @@ -55,6 +56,8 @@ export function getAccessFallbackErrorTypeByStatus( return 'forbidden' case 404: return 'not-found' + case 410: + return 'gone' default: return } diff --git a/packages/next/src/client/components/navigation.react-server.ts b/packages/next/src/client/components/navigation.react-server.ts index 5f767c8ef433e..c09a42e2bfd6a 100644 --- a/packages/next/src/client/components/navigation.react-server.ts +++ b/packages/next/src/client/components/navigation.react-server.ts @@ -29,6 +29,7 @@ class ReadonlyURLSearchParams extends URLSearchParams { export { redirect, permanentRedirect } from './redirect' export { RedirectType } from './redirect-error' export { notFound } from './not-found' +export { gone } from './gone' export { forbidden } from './forbidden' export { unauthorized } from './unauthorized' export { unstable_rethrow } from './unstable-rethrow' diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index ad8342feb8560..ac40883b359ca 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -66,7 +66,12 @@ type ViewportResolver = ( parent: ResolvingViewport ) => Viewport | Promise -export type MetadataErrorType = 'not-found' | 'forbidden' | 'unauthorized' +export type MetadataErrorType = + | 'not-found' + | 'forbidden' + | 'unauthorized' + | 'gone' + | 'gone' export type MetadataItems = [ Metadata | MetadataResolver | null, diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index aef493353d078..7c65a27463b9b 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1852,7 +1852,8 @@ export default abstract class Server< // use the `waitUntil` from there, whether actually present or not -- // if not present, `after` will error. - // NOTE: if we're in an edge runtime sandbox, this context will be used to forward the outer waitUntil. + // NOTE: if we're in an edge runtime sandbox, this context will be used to + // forward the outer waitUntil. return builtinRequestContext.waitUntil } @@ -2016,6 +2017,8 @@ export default abstract class Server< const isErrorPathname = pathname === '/_error' const is404Page = pathname === '/404' || (isErrorPathname && res.statusCode === 404) + const is410Page = + pathname === '/410' || (isErrorPathname && res.statusCode === 410) const is500Page = pathname === '/500' || (isErrorPathname && res.statusCode === 500) const isAppPath = components.isAppPath === true @@ -2106,15 +2109,13 @@ export default abstract class Server< // NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later - const isRSCRequest = getRequestMeta(req, 'isRSCRequest') ?? false - - // when we are handling a middleware prefetch and it doesn't + const isRSCRequest = getRequestMeta(req, 'isRSCRequest') ?? false // when we are handling a middleware prefetch and it doesn't // resolve to a static data route we bail early to avoid // unexpected SSR invocations if ( !isSSG && req.headers['x-middleware-prefetch'] && - !(is404Page || pathname === '/_error') + !(is404Page || is410Page || pathname === '/_error') ) { res.setHeader(MATCHED_PATH_HEADER, pathname) res.setHeader('x-middleware-skip', '1') @@ -2230,13 +2231,16 @@ export default abstract class Server< if (isHtmlBot && isRoutePPREnabled) { isSSG = false this.renderOpts.serveStreamingMetadata = false - } - - // we need to ensure the status code if /404 is visited directly + } // we need to ensure the status code if /404 or /410 is visited directly if (is404Page && !isNextDataRequest && !isRSCRequest) { res.statusCode = 404 } + // ensure the status code if /410 is visited directly + if (is410Page && !isNextDataRequest && !isRSCRequest) { + res.statusCode = 410 + } + // ensure correct status is set when visiting a status page // directly e.g. /500 if (STATIC_STATUS_PAGES.includes(pathname)) { @@ -2249,6 +2253,7 @@ export default abstract class Server< // Resume can use non-GET/HEAD methods. !minimalPostponed && !is404Page && + !is410Page && !is500Page && pathname !== '/_error' && req.method !== 'HEAD' && @@ -2395,8 +2400,7 @@ export default abstract class Server< : resolvedUrlPathname }${query.amp ? '.amp' : ''}` } - - if ((is404Page || is500Page) && isSSG) { + if ((is404Page || is410Page || is500Page) && isSSG) { ssgCacheKey = `${locale ? `/${locale}` : ''}${pathname}${ query.amp ? '.amp' : '' }` @@ -3345,8 +3349,7 @@ export default abstract class Server< // revalidate period from the value that trigged the not found // to be rendered. So if `getStaticProps` returns // { notFound: true, revalidate 60 } the revalidate period should - // be 60 but if a static asset 404s directly it should have a revalidate - // period of 0 so that it doesn't get cached unexpectedly by a CDN + // be 60 but if a static asset 404s directly it should have a revalidate // period of 0 so that it doesn't get cached unexpectedly by a CDN else if (is404Page) { const notFoundRevalidate = getRequestMeta(req, 'notFoundRevalidate') @@ -3355,6 +3358,9 @@ export default abstract class Server< typeof notFoundRevalidate === 'undefined' ? 0 : notFoundRevalidate, expire: undefined, } + } else if (is410Page) { + // For 410 Gone pages, use a revalidate period of 0 to prevent caching + cacheControl = { revalidate: 0, expire: undefined } } else if (is500Page) { cacheControl = { revalidate: 0, expire: undefined } } else if (cacheEntry.cacheControl) { @@ -4222,4 +4228,24 @@ export default abstract class Server< res.statusCode = 404 return this.renderError(null, req, res, pathname!, query, setHeaders) } + + public async render410( + req: ServerRequest, + res: ServerResponse, + parsedUrl?: Pick, + setHeaders = true + ): Promise { + const { pathname, query } = parsedUrl ? parsedUrl : parseUrl(req.url!, true) + + // Ensure the locales are provided on the request meta. + if (this.nextConfig.i18n) { + if (!getRequestMeta(req, 'locale')) { + addRequestMeta(req, 'locale', this.nextConfig.i18n.defaultLocale) + } + addRequestMeta(req, 'defaultLocale', this.nextConfig.i18n.defaultLocale) + } + + res.statusCode = 410 + return this.renderError(null, req, res, pathname!, query, setHeaders) + } } diff --git a/packages/next/src/server/lib/app-dir-module.ts b/packages/next/src/server/lib/app-dir-module.ts index 3e5bc9dfcf582..e8adfe49b6d79 100644 --- a/packages/next/src/server/lib/app-dir-module.ts +++ b/packages/next/src/server/lib/app-dir-module.ts @@ -40,7 +40,7 @@ export async function getLayoutOrPageModule(loaderTree: LoaderTree) { export async function getComponentTypeModule( loaderTree: LoaderTree, - moduleType: 'layout' | 'not-found' | 'forbidden' | 'unauthorized' + moduleType: 'layout' | 'not-found' | 'forbidden' | 'unauthorized' | 'gone' ) { const { [moduleType]: module } = loaderTree[2] if (typeof module !== 'undefined') { diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index ffe7b72b48e9e..065bcf829ce50 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -922,7 +922,7 @@ export default class NextNodeServer extends BaseServer< const imagesConfig = this.nextConfig.images if (imagesConfig.loader !== 'default' || imagesConfig.unoptimized) { - await this.render404(req, res) + await this.render404(req, res, parsedUrl) return true } @@ -1338,6 +1338,20 @@ export default class NextNodeServer extends BaseServer< ) } + public async render410( + req: NodeNextRequest | IncomingMessage, + res: NodeNextResponse | ServerResponse, + parsedUrl?: NextUrlWithParsedQuery, + setHeaders?: boolean + ): Promise { + return super.render410( + this.normalizeReq(req), + this.normalizeRes(res), + parsedUrl, + setHeaders + ) + } + protected getMiddlewareManifest(): MiddlewareManifest | null { if (this.minimalMode) { return null diff --git a/packages/next/src/server/next.ts b/packages/next/src/server/next.ts index 1593d89fbea4c..336570f32d1c1 100644 --- a/packages/next/src/server/next.ts +++ b/packages/next/src/server/next.ts @@ -93,10 +93,13 @@ interface NextWrapperServer { renderErrorToHTML( ...args: Parameters ): ReturnType - render404( ...args: Parameters ): ReturnType + + render410( + ...args: Parameters + ): ReturnType } /** The wrapper server used by `next start` */ @@ -184,6 +187,11 @@ export class NextServer implements NextWrapperServer { return server.render404(...args) } + async render410(...args: Parameters) { + const server = await this.getServer() + return server.render410(...args) + } + async prepare(serverFields?: ServerFields) { const server = await this.getServer() @@ -442,6 +450,10 @@ class NextCustomServer implements NextWrapperServer { return this.server.render404(...args) } + async render410(...args: Parameters) { + return this.server.render410(...args) + } + async close() { await Promise.allSettled([ this.init?.server.close(), diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 6c541f4cab24e..214d1c1b3bef5 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -48,6 +48,7 @@ export type PagesRenderResultMetadata = { cacheControl?: CacheControl assetQueryString?: string isNotFound?: boolean + isGone?: boolean isRedirect?: boolean } diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index bf642a7216d10..ab905de44daba 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -929,7 +929,6 @@ export async function renderToHTMLImpl( if (invalidKeys.length) { throw new Error(invalidKeysMsg('getStaticProps', invalidKeys)) } - if (process.env.NODE_ENV !== 'production') { if ( typeof (data as any).notFound !== 'undefined' && @@ -941,8 +940,27 @@ export async function renderToHTMLImpl( } at the same time. Page: ${pathname}\nSee more info here: https://nextjs.org/docs/messages/gssp-mixed-not-found-redirect` ) } + if ( + typeof (data as any).gone !== 'undefined' && + typeof (data as any).redirect !== 'undefined' + ) { + throw new Error( + `\`redirect\` and \`gone\` can not both be returned from ${ + isSSG ? 'getStaticProps' : 'getServerSideProps' + } at the same time. Page: ${pathname}` + ) + } + if ( + typeof (data as any).notFound !== 'undefined' && + typeof (data as any).gone !== 'undefined' + ) { + throw new Error( + `\`notFound\` and \`gone\` can not both be returned from ${ + isSSG ? 'getStaticProps' : 'getServerSideProps' + } at the same time. Page: ${pathname}` + ) + } } - if ('notFound' in data && data.notFound) { if (pathname === '/404') { throw new Error( @@ -953,6 +971,16 @@ export async function renderToHTMLImpl( metadata.isNotFound = true } + if ('gone' in data && data.gone) { + if (pathname === '/410') { + throw new Error( + `The /410 page can not return gone in "getStaticProps", please remove it to continue!` + ) + } + + metadata.isGone = true + } + if ( 'redirect' in data && data.redirect && @@ -1051,12 +1079,14 @@ export async function renderToHTMLImpl( // pass up cache control and props for export metadata.cacheControl = { revalidate, expire: undefined } - metadata.pageData = props - - // this must come after revalidate is added to renderResultMeta + metadata.pageData = props // this must come after revalidate is added to renderResultMeta if (metadata.isNotFound) { return new RenderResult(null, { metadata }) } + + if (metadata.isGone) { + return new RenderResult(null, { metadata }) + } } if (getServerSideProps) { From 859a9c9966039d4689302cbd92208442877f1f36 Mon Sep 17 00:00:00 2001 From: Sam Sperling Date: Wed, 30 Apr 2025 23:03:39 +1000 Subject: [PATCH 2/6] 410 Gone - Adding unit tests --- test/unit/gone.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 test/unit/gone.test.ts diff --git a/test/unit/gone.test.ts b/test/unit/gone.test.ts new file mode 100644 index 0000000000000..53dbc61170e72 --- /dev/null +++ b/test/unit/gone.test.ts @@ -0,0 +1,13 @@ +import { gone } from 'next/dist/client/components/gone' + +describe('gone', () => { + it('should throw an error with the correct digest', () => { + expect(() => gone()).toThrow() + try { + gone() + } catch (err: any) { + // Use the actual format that's being produced + expect(err.digest).toBe('NEXT_HTTP_ERROR_FALLBACK;410') + } + }) +}) From 73d417bf81b1afc75ba6fecab69dc18547c1ba64 Mon Sep 17 00:00:00 2001 From: Sam Sperling Date: Thu, 1 May 2025 00:20:03 +1000 Subject: [PATCH 3/6] 410 Gone - Adding Examples and e2e / integration tests. --- examples/gone-status/README.md | 61 ++++++ .../gone-status/app/app-posts/[slug]/page.js | 57 +++++ examples/gone-status/app/app-posts/gone.js | 41 ++++ examples/gone-status/app/gone.js | 42 ++++ examples/gone-status/app/page.js | 140 ++++++++++++ examples/gone-status/package.json | 19 ++ examples/gone-status/pages/410.js | 46 ++++ .../gone-status/pages/api/posts/[slug].js | 38 ++++ examples/gone-status/pages/posts/[slug].js | 82 +++++++ .../e2e/app-dir/gone-navigation/index.test.ts | 113 ++++++++++ test/e2e/app-dir/gone/index.test.ts | 74 +++++++ test/e2e/gone-browser-behavior/index.test.ts | 200 ++++++++++++++++++ .../pages/gone-and-not-found.js | 10 + .../pages/gone-and-redirect.js | 13 ++ .../pages/static-gone-and-not-found.js | 12 ++ .../410-error-validation/test/index.test.js | 67 ++++++ .../integration/410-page-support/pages/410.js | 8 + .../410-page-support/pages/gone-page.js | 10 + .../pages/server-side-gone.js | 9 + .../pages/static-props-gone/[slug].js | 24 +++ .../410-page-support/test/index.test.js | 81 +++++++ 21 files changed, 1147 insertions(+) create mode 100644 examples/gone-status/README.md create mode 100644 examples/gone-status/app/app-posts/[slug]/page.js create mode 100644 examples/gone-status/app/app-posts/gone.js create mode 100644 examples/gone-status/app/gone.js create mode 100644 examples/gone-status/app/page.js create mode 100644 examples/gone-status/package.json create mode 100644 examples/gone-status/pages/410.js create mode 100644 examples/gone-status/pages/api/posts/[slug].js create mode 100644 examples/gone-status/pages/posts/[slug].js create mode 100644 test/e2e/app-dir/gone-navigation/index.test.ts create mode 100644 test/e2e/app-dir/gone/index.test.ts create mode 100644 test/e2e/gone-browser-behavior/index.test.ts create mode 100644 test/integration/410-error-validation/pages/gone-and-not-found.js create mode 100644 test/integration/410-error-validation/pages/gone-and-redirect.js create mode 100644 test/integration/410-error-validation/pages/static-gone-and-not-found.js create mode 100644 test/integration/410-error-validation/test/index.test.js create mode 100644 test/integration/410-page-support/pages/410.js create mode 100644 test/integration/410-page-support/pages/gone-page.js create mode 100644 test/integration/410-page-support/pages/server-side-gone.js create mode 100644 test/integration/410-page-support/pages/static-props-gone/[slug].js create mode 100644 test/integration/410-page-support/test/index.test.js diff --git a/examples/gone-status/README.md b/examples/gone-status/README.md new file mode 100644 index 0000000000000..7d889ad45f3c9 --- /dev/null +++ b/examples/gone-status/README.md @@ -0,0 +1,61 @@ +# Next.js 410 Gone Status Example + +This example demonstrates how to use the 410 Gone status in Next.js for content that has been permanently removed. This is useful for SEO and user experience when dealing with deliberately removed content. + +## Features + +- App Router implementation using `gone()` function +- Pages Router implementation using `{ gone: true }` +- Custom 410 Gone error pages +- API Routes returning 410 Gone status + +## How It Works + +### App Router + +In the App Router, you can use the `gone()` function to send a 410 Gone response: + +```jsx +import { gone } from "next/navigation"; + +export default function PostPage({ params }) { + // Check if the post has been deleted + if (isPostDeleted(params.slug)) { + // Return a 410 Gone status + gone(); + } + + // Render the post if it exists + // ... +} +``` + +You can also create custom 410 pages with the `gone.js` file convention. + +### Pages Router + +In the Pages Router, you can return `{ gone: true }` from `getServerSideProps` or `getStaticProps`: + +```jsx +export async function getServerSideProps({ params }) { + // Check if the content has been deleted + if (isContentDeleted(params.id)) { + return { + gone: true, + }; + } + + // Return regular props otherwise + return { + props: { + // ... + }, + }; +} +``` + +## Deployment + +You can deploy this example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/gone-status&project-name=gone-status&repository-name=gone-status) diff --git a/examples/gone-status/app/app-posts/[slug]/page.js b/examples/gone-status/app/app-posts/[slug]/page.js new file mode 100644 index 0000000000000..a2b02787d7a31 --- /dev/null +++ b/examples/gone-status/app/app-posts/[slug]/page.js @@ -0,0 +1,57 @@ +// App Router post page +import { notFound, gone } from "next/navigation"; + +// List of deleted posts +const DELETED_POSTS = ["deleted"]; + +// List of known posts +const KNOWN_POSTS = ["active", "deleted"]; + +export default function PostPage({ params }) { + const { slug } = params; + + // Check if post is deleted + if (DELETED_POSTS.includes(slug)) { + // Return 410 Gone for deleted content + gone(); + } + + // Check if post exists + if (!KNOWN_POSTS.includes(slug)) { + // Return 404 Not Found for unknown content + notFound(); + } + + // Render the active post + return ( +
+

Post: {slug}

+

This is an active post that exists.

+ Return to home + + +
+ ); +} diff --git a/examples/gone-status/app/app-posts/gone.js b/examples/gone-status/app/app-posts/gone.js new file mode 100644 index 0000000000000..09587306a258a --- /dev/null +++ b/examples/gone-status/app/app-posts/gone.js @@ -0,0 +1,41 @@ +// Custom Gone Page for app-posts section +export default function PostGone() { + return ( +
+

Post Permanently Removed

+

+ This post has been permanently removed from our site and will not be + available again. +

+

+ View an active post or{" "} + return to home +

+ + +
+ ); +} diff --git a/examples/gone-status/app/gone.js b/examples/gone-status/app/gone.js new file mode 100644 index 0000000000000..e18bd5d7ab2a2 --- /dev/null +++ b/examples/gone-status/app/gone.js @@ -0,0 +1,42 @@ +// Custom Gone Error Page for App Router +export default function RootGone() { + return ( +
+

410 - Content Gone

+

The content you're looking for has been permanently removed.

+ Return to home page + + +
+ ); +} diff --git a/examples/gone-status/app/page.js b/examples/gone-status/app/page.js new file mode 100644 index 0000000000000..0a608b31c0b84 --- /dev/null +++ b/examples/gone-status/app/page.js @@ -0,0 +1,140 @@ +// App Router - Home Page +export default function Home() { + return ( +
+
+

Next.js 410 Gone Status Example

+ +
+
+

App Router Examples

+ +
+ +
+

Pages Router Examples

+ +
+ +
+

API Examples

+ +
+
+
+ + + + +
+ ); +} diff --git a/examples/gone-status/package.json b/examples/gone-status/package.json new file mode 100644 index 0000000000000..0508c9f0e4c36 --- /dev/null +++ b/examples/gone-status/package.json @@ -0,0 +1,19 @@ +{ + "name": "gone-status", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "latest", + "react": "latest", + "react-dom": "latest" + }, + "devDependencies": { + "@types/node": "^20.1.0", + "@types/react": "^18.2.0", + "typescript": "^5.0.4" + } +} diff --git a/examples/gone-status/pages/410.js b/examples/gone-status/pages/410.js new file mode 100644 index 0000000000000..bbd578c10cc44 --- /dev/null +++ b/examples/gone-status/pages/410.js @@ -0,0 +1,46 @@ +// Custom 410 page for Pages Router +export default function Custom410() { + return ( +
+

410 - Content Gone

+

The content you were looking for has been permanently removed.

+ Return to home page + + +
+ ); +} diff --git a/examples/gone-status/pages/api/posts/[slug].js b/examples/gone-status/pages/api/posts/[slug].js new file mode 100644 index 0000000000000..896c8d53b063b --- /dev/null +++ b/examples/gone-status/pages/api/posts/[slug].js @@ -0,0 +1,38 @@ +// API route example for handling 410 Gone status + +// List of deleted posts +const DELETED_POSTS = ["deleted"]; + +// List of known posts +const KNOWN_POSTS = ["active", "deleted"]; + +export default function handler(req, res) { + const { slug } = req.query; + + // Check if post is deleted + if (DELETED_POSTS.includes(slug)) { + // Return 410 Gone for deleted content + res.status(410).json({ + error: "Gone", + message: "This post has been permanently removed", + }); + return; + } + + // Check if post exists + if (!KNOWN_POSTS.includes(slug)) { + // Return 404 Not Found for unknown content + res.status(404).json({ + error: "Not Found", + message: "Post not found", + }); + return; + } + + // Return data for active post + res.status(200).json({ + slug, + title: `Post ${slug}`, + content: "This is the content of an active post.", + }); +} diff --git a/examples/gone-status/pages/posts/[slug].js b/examples/gone-status/pages/posts/[slug].js new file mode 100644 index 0000000000000..c5b4391dc79bd --- /dev/null +++ b/examples/gone-status/pages/posts/[slug].js @@ -0,0 +1,82 @@ +// Pages Router post page +import Head from "next/head"; +import { useRouter } from "next/router"; + +// List of deleted posts +const DELETED_POSTS = ["deleted"]; + +// List of known posts +const KNOWN_POSTS = ["active", "deleted"]; + +export default function Post({ slug }) { + const router = useRouter(); + + if (router.isFallback) { + return
Loading...
; + } + + return ( + <> + + Post: {slug} + + +
+

Post: {slug}

+

This is an active post that exists in the Pages Router.

+ Return to home + + +
+ + ); +} + +export async function getServerSideProps({ params }) { + const { slug } = params; + + // Check if post is deleted + if (DELETED_POSTS.includes(slug)) { + // Return 410 Gone for deleted content + return { + gone: true, + }; + } + + // Check if post exists + if (!KNOWN_POSTS.includes(slug)) { + // Return 404 Not Found for unknown content + return { + notFound: true, + }; + } + + // Return props for active post + return { + props: { + slug, + }, + }; +} diff --git a/test/e2e/app-dir/gone-navigation/index.test.ts b/test/e2e/app-dir/gone-navigation/index.test.ts new file mode 100644 index 0000000000000..58e0a6773f2a9 --- /dev/null +++ b/test/e2e/app-dir/gone-navigation/index.test.ts @@ -0,0 +1,113 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'app-dir gone-navigation', + { + files: { + 'app/page.js': ` + export default function Home() { + return ( +
+

Home Page

+ +
+ ) + } + `, + 'app/posts/[slug]/page.js': ` + import { gone, notFound } from 'next/navigation' + + export default function Post({ params }) { + if (params.slug === 'deleted') { + gone() + } + + if (params.slug !== 'active') { + notFound() + } + + return ( +
+

Post: {params.slug}

+ Back to Home +
+ ) + } + `, + 'app/posts/gone.js': ` + export default function PostGone() { + return ( +
+

Post Permanently Removed

+ Home +
+ ) + } + `, + 'app/not-found.js': ` + export default function NotFound() { + return ( +
+

Not Found

+ Home +
+ ) + } + `, + }, + dependencies: {}, + }, + ({ next, page }) => { + it('should navigate to 410 gone page when client-side navigating to a gone page', async () => { + // Start on the home page + const homeUrl = next.url + await page.goto(homeUrl) + + // Navigate to the deleted post using client-side navigation + await page.click('#link-deleted') + await page.waitForSelector('#gone-title') + + // Verify the URL is correct + expect(page.url()).toContain('/posts/deleted') + + // Verify we're showing the gone page + const goneTitle = await page.textContent('#gone-title') + expect(goneTitle).toBe('Post Permanently Removed') + + // Verify HTTP status is 410 + const response = await page.request.get(`${homeUrl}/posts/deleted`) + expect(response.status()).toBe(410) + }) + + it('should navigate back correctly from a gone page', async () => { + // Start on the deleted post page + await page.goto(`${next.url}/posts/deleted`) + await page.waitForSelector('#gone-title') + + // Click the back to home link + await page.click('#gone-home-link') + await page.waitForSelector('#link-active') + + // Verify we're back on the home page + expect(page.url()).toBe(next.url + '/') + }) + + it('should distinguish between gone and not found', async () => { + // Navigate to the unknown post + await page.goto(`${next.url}/posts/unknown`) + await page.waitForSelector('#not-found-title') + + // Verify we're showing the not-found page + const notFoundTitle = await page.textContent('#not-found-title') + expect(notFoundTitle).toBe('Not Found') + + // Verify HTTP status is 404 + const response = await page.request.get(`${next.url}/posts/unknown`) + expect(response.status()).toBe(404) + }) + } +) diff --git a/test/e2e/app-dir/gone/index.test.ts b/test/e2e/app-dir/gone/index.test.ts new file mode 100644 index 0000000000000..e680781f329a6 --- /dev/null +++ b/test/e2e/app-dir/gone/index.test.ts @@ -0,0 +1,74 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'app-dir gone()', + { + files: { + 'app/page.js': ` + export default function Page() { + return

Home Page

+ } + `, + 'app/gone/page.js': ` + import { gone } from 'next/navigation' + + export default function GonePage() { + gone() + return

This will not be rendered

+ } + `, + 'app/gone/gone.js': ` + export default function CustomGone() { + return

Custom Gone Page

+ } + `, + 'app/nested/[slug]/page.js': ` + export function generateStaticParams() { + return [ + { slug: 'test' } + ] + } + + export default function Page({ params }) { + if (params.slug === 'removed') { + const { gone } = require('next/navigation') + gone() + } + return

Slug: {params.slug}

+ } + `, + 'app/nested/gone.js': ` + export default function NestedGone() { + return

Nested Gone Page

+ } + `, + }, + dependencies: {}, + }, + ({ next }) => { + it('should render the custom gone page with 410 status when gone() is called', async () => { + const response = await next.fetch('/gone') + expect(response.status).toBe(410) + + const html = await response.text() + expect(html).toContain('Custom Gone Page') + expect(html).toContain(' { + const response = await next.fetch('/nested/removed') + expect(response.status).toBe(410) + + const html = await response.text() + expect(html).toContain('Nested Gone Page') + }) + + it('should render the normal page when not calling gone()', async () => { + const response = await next.fetch('/nested/test') + expect(response.status).toBe(200) + + const html = await response.text() + expect(html).toContain('Slug: test') + }) + } +) diff --git a/test/e2e/gone-browser-behavior/index.test.ts b/test/e2e/gone-browser-behavior/index.test.ts new file mode 100644 index 0000000000000..825dbbfab2850 --- /dev/null +++ b/test/e2e/gone-browser-behavior/index.test.ts @@ -0,0 +1,200 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'gone-browser-behavior', + { + files: { + 'pages/_app.js': ` + import { useRouter } from 'next/router' + import { useEffect } from 'react' + + export default function App({ Component, pageProps }) { + const router = useRouter() + + useEffect(() => { + if (typeof window !== 'undefined') { + window.routerEvents = [] + const events = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError'] + + events.forEach((event) => { + router.events.on(event, (...args) => { + window.routerEvents.push({ event, args }) + }) + }) + } + }, [router]) + + return + } + `, + 'pages/index.js': ` + import Link from 'next/link' + + export default function Home() { + return ( +
+

Home Page

+ +
+ ) + } + `, + 'pages/active.js': ` + import Link from 'next/link' + + export default function ActivePage() { + return ( +
+

Active Page

+ Back to Home +
+ ) + } + `, + 'pages/deleted.js': ` + export default function DeletedPage() { + return
This should not be rendered
+ } + + export function getServerSideProps() { + return { + gone: true + } + } + `, + 'pages/unknown.js': ` + export default function UnknownPage() { + return
This should not be rendered
+ } + + export function getServerSideProps() { + return { + notFound: true + } + } + `, + 'pages/410.js': ` + export default function Custom410() { + return ( +
+

410 - Content Gone

+

This content has been permanently removed.

+ Back to Home +
+ ) + } + `, + 'pages/404.js': ` + export default function Custom404() { + return ( +
+

404 - Page Not Found

+ Back to Home +
+ ) + } + `, + }, + dependencies: {}, + }, + ({ next, page }) => { + it('should load custom 410 page with proper status code for direct navigation', async () => { + const response = await page.goto(`${next.url}/deleted`, { + waitUntil: 'networkidle', + }) + + // Verify status code is 410 + expect(response.status()).toBe(410) + + // Verify the custom 410 page is rendered + const title = await page.textContent('#gone-title') + expect(title).toBe('410 - Content Gone') + + // Verify the page has noindex meta tag + const hasNoIndexTag = await page.evaluate(() => { + const metaTag = document.querySelector( + 'meta[name="robots"][content="noindex"]' + ) + return !!metaTag + }) + expect(hasNoIndexTag).toBe(true) + }) + + it('should load custom 410 page for client-side navigation', async () => { + // Start on home page + await page.goto(next.url) + + // Clear any previous router events + await page.evaluate(() => { + window.routerEvents = [] + }) + + // Click link to deleted page + await page.click('#link-deleted') + + // Wait for 410 page to load + await page.waitForSelector('#gone-title') + + // Verify the custom 410 page is rendered + const title = await page.textContent('#gone-title') + expect(title).toBe('410 - Content Gone') + + // Verify URL in browser + expect(page.url()).toContain('/deleted') + + // Verify router events indicate error + const routerEvents = await page.evaluate(() => window.routerEvents) + + // Should have routeChangeStart followed by routeChangeError + expect(routerEvents[0].event).toBe('routeChangeStart') + expect(routerEvents[1].event).toBe('routeChangeError') + }) + + it('should properly handle back/forward navigation involving gone pages', async () => { + // Start on home page + await page.goto(next.url) + + // Navigate to active page + await page.click('#link-active') + await page.waitForSelector('#back-link') + + // Navigate to deleted page (shows 410) + await page.goto(`${next.url}/deleted`) + await page.waitForSelector('#gone-title') + + // Go back to active page + await page.goBack() + + // Wait for active page to load + await page.waitForSelector('#back-link') + expect(page.url()).toContain('/active') + + // Go forward again to deleted page + await page.goForward() + + // Wait for 410 page to load + await page.waitForSelector('#gone-title') + expect(page.url()).toContain('/deleted') + }) + + it('should distinguish between 410 and 404 status codes', async () => { + // First check the 410 page + const goneResponse = await page.goto(`${next.url}/deleted`) + expect(goneResponse.status()).toBe(410) + + // Then check the 404 page + const notFoundResponse = await page.goto(`${next.url}/unknown`) + expect(notFoundResponse.status()).toBe(404) + + // Verify correct page is shown + const title = await page.textContent('#not-found-title') + expect(title).toBe('404 - Page Not Found') + }) + } +) diff --git a/test/integration/410-error-validation/pages/gone-and-not-found.js b/test/integration/410-error-validation/pages/gone-and-not-found.js new file mode 100644 index 0000000000000..6a3422eac5272 --- /dev/null +++ b/test/integration/410-error-validation/pages/gone-and-not-found.js @@ -0,0 +1,10 @@ +export default function GoneAndNotFound() { + return

This page should not be rendered

+} + +export function getServerSideProps() { + return { + notFound: true, + gone: true, + } +} diff --git a/test/integration/410-error-validation/pages/gone-and-redirect.js b/test/integration/410-error-validation/pages/gone-and-redirect.js new file mode 100644 index 0000000000000..704ade4418c65 --- /dev/null +++ b/test/integration/410-error-validation/pages/gone-and-redirect.js @@ -0,0 +1,13 @@ +export default function GoneAndRedirect() { + return

This page should not be rendered

+} + +export function getServerSideProps() { + return { + redirect: { + destination: '/', + permanent: false, + }, + gone: true, + } +} diff --git a/test/integration/410-error-validation/pages/static-gone-and-not-found.js b/test/integration/410-error-validation/pages/static-gone-and-not-found.js new file mode 100644 index 0000000000000..5b31b4139cbed --- /dev/null +++ b/test/integration/410-error-validation/pages/static-gone-and-not-found.js @@ -0,0 +1,12 @@ +// This file will cause a build error +export default function StaticGoneAndNotFound() { + return

This page should not be rendered

+} + +export function getStaticProps() { + return { + notFound: true, + gone: true, + props: {}, + } +} diff --git a/test/integration/410-error-validation/test/index.test.js b/test/integration/410-error-validation/test/index.test.js new file mode 100644 index 0000000000000..b1b342e257d10 --- /dev/null +++ b/test/integration/410-error-validation/test/index.test.js @@ -0,0 +1,67 @@ +/* eslint-env jest */ +import fs from 'fs-extra' +import { join } from 'path' +import { + findPort, + killApp, + launchApp, + nextBuild, + renderViaHTTP, +} from 'next-test-utils' + +const appDir = join(__dirname, '../') +const nextConfig = join(appDir, 'next.config.js') +let appPort +let app +const nextConfigContent = ` +module.exports = { + reactStrictMode: true, +} +` + +describe('410 Error Validation', () => { + beforeAll(async () => { + await fs.writeFile(nextConfig, nextConfigContent, 'utf8') + }) + + afterAll(async () => { + await fs.remove(nextConfig) + }) + + describe('Development Mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort, { + env: { NODE_ENV: 'development' }, + }) + }) + + afterAll(async () => { + await killApp(app) + }) + + it('should show error when both gone and notFound are returned', async () => { + const html = await renderViaHTTP(appPort, '/gone-and-not-found') + expect(html).toContain( + '`notFound` and `gone` can not both be returned from getServerSideProps' + ) + }) + + it('should show error when both gone and redirect are returned', async () => { + const html = await renderViaHTTP(appPort, '/gone-and-redirect') + expect(html).toContain( + '`redirect` and `gone` can not both be returned from getServerSideProps' + ) + }) + }) + + describe('Build Mode', () => { + it('should fail build when both gone and notFound are returned in getStaticProps', async () => { + const { stderr, code } = await nextBuild(appDir, [], { stderr: true }) + expect(code).toBe(1) + expect(stderr).toContain( + '`notFound` and `gone` can not both be returned from getStaticProps' + ) + }) + }) +}) diff --git a/test/integration/410-page-support/pages/410.js b/test/integration/410-page-support/pages/410.js new file mode 100644 index 0000000000000..f5c302d5f1997 --- /dev/null +++ b/test/integration/410-page-support/pages/410.js @@ -0,0 +1,8 @@ +export default function Custom410Page() { + return ( +
+

Custom 410 Page

+

This content has been permanently removed.

+
+ ) +} diff --git a/test/integration/410-page-support/pages/gone-page.js b/test/integration/410-page-support/pages/gone-page.js new file mode 100644 index 0000000000000..689fe59d585bd --- /dev/null +++ b/test/integration/410-page-support/pages/gone-page.js @@ -0,0 +1,10 @@ +export default function GonePage() { + return

This is a page that triggers the 410 status code

+} + +export function getServerSideProps() { + return { + props: {}, + gone: true, + } +} diff --git a/test/integration/410-page-support/pages/server-side-gone.js b/test/integration/410-page-support/pages/server-side-gone.js new file mode 100644 index 0000000000000..2172eaa475dbf --- /dev/null +++ b/test/integration/410-page-support/pages/server-side-gone.js @@ -0,0 +1,9 @@ +export async function getServerSideProps() { + return { + gone: true, + } +} + +export default function GonePage() { + return

This should not be rendered

+} diff --git a/test/integration/410-page-support/pages/static-props-gone/[slug].js b/test/integration/410-page-support/pages/static-props-gone/[slug].js new file mode 100644 index 0000000000000..1bcf67f02fb8e --- /dev/null +++ b/test/integration/410-page-support/pages/static-props-gone/[slug].js @@ -0,0 +1,24 @@ +export async function getStaticPaths() { + return { + paths: [{ params: { slug: 'test' } }], + fallback: true, + } +} + +export async function getStaticProps({ params }) { + if (params.slug === 'deleted') { + return { + gone: true, + } + } + + return { + props: { + slug: params.slug, + }, + } +} + +export default function StaticPropsPage({ slug }) { + return

Slug: {slug}

+} diff --git a/test/integration/410-page-support/test/index.test.js b/test/integration/410-page-support/test/index.test.js new file mode 100644 index 0000000000000..f6179d8d473b0 --- /dev/null +++ b/test/integration/410-page-support/test/index.test.js @@ -0,0 +1,81 @@ +/* eslint-env jest */ +import { join } from 'path' +import { + findPort, + killApp, + launchApp, + nextBuild, + nextStart, + fetchViaHTTP, +} from 'next-test-utils' + +const appDir = join(__dirname, '../') +let appPort +let app + +describe('410 Page Support', () => { + describe('Development Mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + + afterAll(async () => { + await killApp(app) + }) + + it('should render the custom 410 page with the correct status code', async () => { + const res = await fetchViaHTTP(appPort, '/gone-page') + expect(res.status).toBe(410) + + const html = await res.text() + expect(html).toContain('Custom 410 Page') + }) + + it('should handle getServerSideProps returning gone: true', async () => { + const res = await fetchViaHTTP(appPort, '/server-side-gone') + expect(res.status).toBe(410) + }) + + it('should handle getStaticProps returning gone: true with fallback: true', async () => { + const res = await fetchViaHTTP(appPort, '/static-props-gone/deleted') + expect(res.status).toBe(410) + }) + }) + + describe('Production Mode', () => { + beforeAll(async () => { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + + afterAll(async () => { + await killApp(app) + }) + + it('should render the custom 410 page with the correct status code', async () => { + const res = await fetchViaHTTP(appPort, '/gone-page') + expect(res.status).toBe(410) + + const html = await res.text() + expect(html).toContain('Custom 410 Page') + }) + + it('should handle getServerSideProps returning gone: true', async () => { + const res = await fetchViaHTTP(appPort, '/server-side-gone') + expect(res.status).toBe(410) + }) + + it('should handle getStaticProps returning gone: true with fallback: true', async () => { + const res = await fetchViaHTTP(appPort, '/static-props-gone/deleted') + expect(res.status).toBe(410) + }) + + it('should include noindex meta tag', async () => { + const res = await fetchViaHTTP(appPort, '/gone-page') + const html = await res.text() + expect(html).toContain('