diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts
index 3510aefe12..6511b16c13 100644
--- a/src/build/content/prerendered.ts
+++ b/src/build/content/prerendered.ts
@@ -57,6 +57,7 @@ const routeToFilePath = (path: string) => {
const buildPagesCacheValue = async (
path: string,
+ initialRevalidateSeconds: number | false | undefined,
shouldUseEnumKind: boolean,
shouldSkipJson = false,
): Promise => ({
@@ -65,6 +66,7 @@ const buildPagesCacheValue = async (
pageData: shouldSkipJson ? {} : JSON.parse(await readFile(`${path}.json`, 'utf-8')),
headers: undefined,
status: undefined,
+ revalidate: initialRevalidateSeconds,
})
const buildAppCacheValue = async (
@@ -178,6 +180,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise
}
value = await buildPagesCacheValue(
join(ctx.publishDir, 'server/pages', key),
+ meta.initialRevalidateSeconds,
shouldUseEnumKind,
)
break
@@ -210,6 +213,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise
const key = routeToFilePath(route)
const value = await buildPagesCacheValue(
join(ctx.publishDir, 'server/pages', key),
+ undefined,
shouldUseEnumKind,
true, // there is no corresponding json file for fallback, so we are skipping it for this entry
)
diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts
index a2f84ab125..e40caff469 100644
--- a/src/run/handlers/cache.cts
+++ b/src/run/handlers/cache.cts
@@ -308,6 +308,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
case 'PAGES': {
const { revalidate, ...restOfPageValue } = blob.value
+ const requestContext = getRequestContext()
+ if (requestContext) {
+ requestContext.pageHandlerRevalidate = revalidate
+ }
+
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })
await this.injectEntryToPrerenderManifest(key, revalidate)
diff --git a/src/run/handlers/request-context.cts b/src/run/handlers/request-context.cts
index 82f4f8567f..36e27e35a9 100644
--- a/src/run/handlers/request-context.cts
+++ b/src/run/handlers/request-context.cts
@@ -25,6 +25,7 @@ export type RequestContext = {
didPagesRouterOnDemandRevalidate?: boolean
serverTiming?: string
routeHandlerRevalidate?: NetlifyCachedRouteValue['revalidate']
+ pageHandlerRevalidate?: NetlifyCachedRouteValue['revalidate']
/**
* Track promise running in the background and need to be waited for.
* Uses `context.waitUntil` if available, otherwise stores promises to
diff --git a/src/run/headers.ts b/src/run/headers.ts
index 91d4588528..50d82d4c16 100644
--- a/src/run/headers.ts
+++ b/src/run/headers.ts
@@ -2,6 +2,7 @@ import type { Span } from '@opentelemetry/api'
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
import { encodeBlobKey } from '../shared/blobkey.js'
+import type { NetlifyCachedRouteValue } from '../shared/cache-types.cjs'
import { getLogger, RequestContext } from './handlers/request-context.cjs'
import type { RuntimeTracer } from './handlers/tracer.cjs'
@@ -197,6 +198,19 @@ export const adjustDateHeader = async ({
headers.set('date', lastModifiedDate.toUTCString())
}
+function setCacheControlFromRequestContext(
+ headers: Headers,
+ revalidate: NetlifyCachedRouteValue['revalidate'],
+) {
+ const cdnCacheControl =
+ // if we are serving already stale response, instruct edge to not attempt to cache that response
+ headers.get('x-nextjs-cache') === 'STALE'
+ ? 'public, max-age=0, must-revalidate, durable'
+ : `s-maxage=${revalidate || 31536000}, stale-while-revalidate=31536000, durable`
+
+ headers.set('netlify-cdn-cache-control', cdnCacheControl)
+}
+
/**
* Ensure stale-while-revalidate and s-maxage don't leak to the client, but
* assume the user knows what they are doing if CDN cache controls are set
@@ -214,13 +228,7 @@ export const setCacheControlHeaders = (
!headers.has('netlify-cdn-cache-control')
) {
// handle CDN Cache Control on Route Handler responses
- const cdnCacheControl =
- // if we are serving already stale response, instruct edge to not attempt to cache that response
- headers.get('x-nextjs-cache') === 'STALE'
- ? 'public, max-age=0, must-revalidate, durable'
- : `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000, durable`
-
- headers.set('netlify-cdn-cache-control', cdnCacheControl)
+ setCacheControlFromRequestContext(headers, requestContext.routeHandlerRevalidate)
return
}
@@ -231,14 +239,27 @@ export const setCacheControlHeaders = (
.log('NetlifyHeadersHandler.trailingSlashRedirect')
}
- if (status === 404 && request.url.endsWith('.php')) {
- // temporary CDN Cache Control handling for bot probes on PHP files
- // https://linear.app/netlify/issue/FRB-1344/prevent-excessive-ssr-invocations-due-to-404-routes
- headers.set('cache-control', 'public, max-age=0, must-revalidate')
- headers.set('netlify-cdn-cache-control', `max-age=31536000, durable`)
+ const cacheControl = headers.get('cache-control')
+ if (status === 404) {
+ if (request.url.endsWith('.php')) {
+ // temporary CDN Cache Control handling for bot probes on PHP files
+ // https://linear.app/netlify/issue/FRB-1344/prevent-excessive-ssr-invocations-due-to-404-routes
+ headers.set('cache-control', 'public, max-age=0, must-revalidate')
+ headers.set('netlify-cdn-cache-control', `max-age=31536000, durable`)
+ return
+ }
+
+ if (
+ process.env.CACHE_404_PAGE &&
+ request.url.endsWith('/404') &&
+ ['GET', 'HEAD'].includes(request.method)
+ ) {
+ // handle CDN Cache Control on 404 Page responses
+ setCacheControlFromRequestContext(headers, requestContext.pageHandlerRevalidate)
+ return
+ }
}
- const cacheControl = headers.get('cache-control')
if (
cacheControl !== null &&
['GET', 'HEAD'].includes(request.method) &&
diff --git a/tests/e2e/page-router.test.ts b/tests/e2e/page-router.test.ts
index 0cbe296623..bcd5645de7 100644
--- a/tests/e2e/page-router.test.ts
+++ b/tests/e2e/page-router.test.ts
@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
-import { test } from '../utils/playwright-helpers.js'
import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
+import { test } from '../utils/playwright-helpers.js'
export function waitFor(millis: number) {
return new Promise((resolve) => setTimeout(resolve, millis))
@@ -462,7 +462,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
const headers = response?.headers() || {}
expect(response?.status()).toBe(404)
- expect(await page.textContent('h1')).toBe('404')
+ expect(await page.textContent('p')).toBe('Custom 404 page')
// https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
// after that (14.2.10 and canary.147) 404 pages would have `private` directive, before that
@@ -486,7 +486,7 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
const headers = response?.headers() || {}
expect(response?.status()).toBe(404)
- expect(await page.textContent('h1')).toBe('404')
+ expect(await page.textContent('p')).toBe('Custom 404 page')
expect(headers['netlify-cdn-cache-control']).toBe(
nextVersionSatisfies('>=15.0.0-canary.187')
@@ -1326,7 +1326,7 @@ test.describe('Page Router with basePath and i18n', () => {
const headers = response?.headers() || {}
expect(response?.status()).toBe(404)
- expect(await page.textContent('h1')).toBe('404')
+ expect(await page.textContent('p')).toBe('Custom 404 page')
// https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
// after that 404 pages would have `private` directive, before that it would not
@@ -1349,7 +1349,7 @@ test.describe('Page Router with basePath and i18n', () => {
const headers = response?.headers() || {}
expect(response?.status()).toBe(404)
- expect(await page.textContent('h1')).toBe('404')
+ expect(await page.textContent('p')).toBe('Custom 404 page')
expect(headers['netlify-cdn-cache-control']).toBe(
nextVersionSatisfies('>=15.0.0-canary.187')
diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/next.config.js b/tests/fixtures/page-router-404-get-static-props-with-revalidate/next.config.js
new file mode 100644
index 0000000000..6346ab0742
--- /dev/null
+++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/next.config.js
@@ -0,0 +1,10 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: 'standalone',
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ generateBuildId: () => 'build-id',
+}
+
+module.exports = nextConfig
diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/package.json b/tests/fixtures/page-router-404-get-static-props-with-revalidate/package.json
new file mode 100644
index 0000000000..4506b1ddb2
--- /dev/null
+++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "page-router-404-get-static-props-with-revalidate",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "postinstall": "next build",
+ "dev": "next dev",
+ "build": "next build"
+ },
+ "dependencies": {
+ "next": "latest",
+ "react": "18.2.0",
+ "react-dom": "18.2.0"
+ }
+}
diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/404.js b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/404.js
new file mode 100644
index 0000000000..cb047699af
--- /dev/null
+++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/404.js
@@ -0,0 +1,17 @@
+export default function NotFound({ timestamp }) {
+ return (
+
+ Custom 404 page with revalidate:
{timestamp}
+
+ )
+}
+
+/** @type {import('next').GetStaticProps} */
+export const getStaticProps = ({ locale }) => {
+ return {
+ props: {
+ timestamp: Date.now(),
+ },
+ revalidate: 300,
+ }
+}
diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/products/[slug].js b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/products/[slug].js
new file mode 100644
index 0000000000..714c8ac143
--- /dev/null
+++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/products/[slug].js
@@ -0,0 +1,40 @@
+const Product = ({ time, slug }) => (
+
+
Product {slug}
+
+ This page uses getStaticProps() and getStaticPaths() to pre-fetch a Product
+ {time}
+
+
+)
+
+/** @type {import('next').GetStaticProps} */
+export async function getStaticProps({ params }) {
+ if (params.slug === 'not-found-no-revalidate') {
+ return {
+ notFound: true,
+ }
+ } else if (params.slug === 'not-found-with-revalidate') {
+ return {
+ notFound: true,
+ revalidate: 600,
+ }
+ }
+
+ return {
+ props: {
+ time: new Date().toISOString(),
+ slug: params.slug,
+ },
+ }
+}
+
+/** @type {import('next').GetStaticPaths} */
+export const getStaticPaths = () => {
+ return {
+ paths: [],
+ fallback: 'blocking', // false or "blocking"
+ }
+}
+
+export default Product
diff --git a/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js b/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js
index f41d142c67..306314ab1f 100644
--- a/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js
+++ b/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js
@@ -8,7 +8,19 @@ const Product = ({ time, slug }) => (
)
+/** @type {import('next').GetStaticProps} */
export async function getStaticProps({ params }) {
+ if (params.slug === 'not-found-no-revalidate') {
+ return {
+ notFound: true,
+ }
+ } else if (params.slug === 'not-found-with-revalidate') {
+ return {
+ notFound: true,
+ revalidate: 600,
+ }
+ }
+
return {
props: {
time: new Date().toISOString(),
diff --git a/tests/fixtures/page-router/pages/404.js b/tests/fixtures/page-router/pages/404.js
new file mode 100644
index 0000000000..3c251e6665
--- /dev/null
+++ b/tests/fixtures/page-router/pages/404.js
@@ -0,0 +1,3 @@
+export default function NotFound() {
+ return Custom 404 page
+}
diff --git a/tests/fixtures/page-router/pages/products/[slug].js b/tests/fixtures/page-router/pages/products/[slug].js
index a55c3d0991..319b1f9213 100644
--- a/tests/fixtures/page-router/pages/products/[slug].js
+++ b/tests/fixtures/page-router/pages/products/[slug].js
@@ -9,6 +9,13 @@ const Product = ({ time, slug }) => (
)
export async function getStaticProps({ params }) {
+ if (params.slug === 'not-found-with-revalidate') {
+ return {
+ notFound: true,
+ revalidate: 600,
+ }
+ }
+
return {
props: {
time: new Date().toISOString(),
diff --git a/tests/integration/page-router.test.ts b/tests/integration/page-router.test.ts
index eb039eb79d..25dbfaba41 100644
--- a/tests/integration/page-router.test.ts
+++ b/tests/integration/page-router.test.ts
@@ -4,7 +4,7 @@ import { HttpResponse, http, passthrough } from 'msw'
import { setupServer } from 'msw/node'
import { platform } from 'node:process'
import { v4 } from 'uuid'
-import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { type FixtureTestContext } from '../utils/contexts.js'
import { createFixture, invokeFunction, runPlugin } from '../utils/fixture.js'
import { encodeBlobKey, generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
@@ -19,7 +19,6 @@ beforeAll(() => {
// and passthrough everything else
server = setupServer(
http.post('https://api.netlify.com/api/v1/purge', () => {
- console.log('intercepted purge api call')
return HttpResponse.json({})
}),
http.all(/.*/, () => passthrough()),
@@ -91,7 +90,6 @@ test('Should revalidate path with On-demand Revalidation', a
expect(staticPageRevalidated.headers?.['cache-status']).toMatch(/"Next.js"; hit/)
const dateCacheRevalidated = load(staticPageRevalidated.body)('[data-testid="date-now"]').text()
- console.log({ dateCacheInitial, dateCacheRevalidated })
expect(dateCacheInitial).not.toBe(dateCacheRevalidated)
})
@@ -153,3 +151,189 @@ test('Should serve correct locale-aware custom 404 pages', a
'Served 404 page content should use non-default locale if non-default locale is explicitly used in pathname (after basePath)',
).toBe('fr')
})
+
+// These tests describe how the 404 caching should work, but unfortunately it doesn't work like
+// this in v5 and a fix would represent a breaking change so we are skipping them for now, but
+// leaving them here for future reference when considering the next major version
+describe.skip('404 caching', () => {
+ describe('404 without getStaticProps', () => {
+ test('not matching dynamic paths should be cached permanently', async (ctx) => {
+ await createFixture('page-router', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/not-existing-page',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
+ })
+ test('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
+ await createFixture('page-router', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/products/not-found-with-revalidate',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for revalidate time',
+ ).toBe('s-maxage=600, stale-while-revalidate=31536000, durable')
+ })
+ })
+
+ describe('404 with getStaticProps without revalidate', () => {
+ test('not matching dynamic paths should be cached permanently', async (ctx) => {
+ await createFixture('page-router-base-path-i18n', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/base/path/not-existing-page',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
+ })
+ test('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
+ await createFixture('page-router-base-path-i18n', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/base/path/products/not-found-with-revalidate',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for revalidate time',
+ ).toBe('s-maxage=600, stale-while-revalidate=31536000, durable')
+ })
+ })
+
+ describe('404 with getStaticProps with revalidate', () => {
+ test('not matching dynamic paths should be cached for 404 page revalidate', async (ctx) => {
+ await createFixture('page-router-404-get-static-props-with-revalidate', ctx)
+ await runPlugin(ctx)
+
+ // ignoring initial stale case
+ await invokeFunction(ctx, {
+ url: 'not-existing-page',
+ })
+
+ await new Promise((res) => setTimeout(res, 100))
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: 'not-existing-page',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for 404 page revalidate',
+ ).toBe('s-maxage=300, stale-while-revalidate=31536000, durable')
+ })
+
+ test('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
+ await createFixture('page-router-404-get-static-props-with-revalidate', ctx)
+ await runPlugin(ctx)
+
+ // ignoring initial stale case
+ await invokeFunction(ctx, {
+ url: 'products/not-found-with-revalidate',
+ })
+ await new Promise((res) => setTimeout(res, 100))
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: 'products/not-found-with-revalidate',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for revalidate time',
+ ).toBe('s-maxage=600, stale-while-revalidate=31536000, durable')
+ })
+ })
+})
+
+// This is a temporary fix to ensure that the 404 page itself is cached correctly when requested
+// directly. This is a workaround for a specific customer and should be removed once the 404 caching
+// is fixed in the next major version.
+describe('404 page caching', () => {
+ beforeAll(() => {
+ process.env.CACHE_404_PAGE = 'true'
+ })
+
+ afterAll(() => {
+ delete process.env.CACHE_404_PAGE
+ })
+
+ test('404 without getStaticProps', async (ctx) => {
+ await createFixture('page-router', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/404',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
+ })
+
+ test('404 with getStaticProps without revalidate', async (ctx) => {
+ await createFixture('page-router-base-path-i18n', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/base/404',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
+ })
+
+ test('404 with getStaticProps with revalidate', async (ctx) => {
+ await createFixture('page-router-404-get-static-props-with-revalidate', ctx)
+ await runPlugin(ctx)
+
+ // ignoring initial stale case
+ await invokeFunction(ctx, {
+ url: '/404',
+ })
+
+ await new Promise((res) => setTimeout(res, 100))
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/404',
+ })
+
+ expect(notExistingPage.statusCode).toBe(404)
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for 404 page revalidate',
+ ).toBe('s-maxage=300, stale-while-revalidate=31536000, durable')
+ })
+})
diff --git a/tests/integration/static.test.ts b/tests/integration/static.test.ts
index c0fb42fd74..b332577ca4 100644
--- a/tests/integration/static.test.ts
+++ b/tests/integration/static.test.ts
@@ -54,7 +54,7 @@ test('requesting a non existing page route that needs to be
// test that it should request the 404.html file
const call1 = await invokeFunction(ctx, { url: 'static/revalidate-not-existing' })
expect(call1.statusCode).toBe(404)
- expect(load(call1.body)('h1').text()).toBe('404')
+ expect(load(call1.body)('p').text()).toBe('Custom 404 page')
// https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
// after that (14.2.10 and canary.147) 404 pages would have `private` directive, before that it