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 c00a4d5e14..8c2a06956d 100644
--- a/src/run/handlers/cache.cts
+++ b/src/run/handlers/cache.cts
@@ -260,6 +260,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
const { revalidate, ...restOfPageValue } = blob.value
+ const requestContext = getRequestContext()
+ if (requestContext) {
+ requestContext.pageHandlerRevalidate = revalidate
+ }
+
await this.injectEntryToPrerenderManifest(key, revalidate)
return {
diff --git a/src/run/handlers/request-context.cts b/src/run/handlers/request-context.cts
index cc67739242..af4083e1ca 100644
--- a/src/run/handlers/request-context.cts
+++ b/src/run/handlers/request-context.cts
@@ -21,6 +21,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 c93c0a605b..4a1f4682ab 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'
@@ -208,6 +209,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 === false ? 31536000 : revalidate}, 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
@@ -225,13 +239,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
}
@@ -242,14 +250,29 @@ 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 (
+ (!cacheControl || cacheControl.includes('no-store')) &&
+ typeof requestContext.pageHandlerRevalidate !== 'undefined' &&
+ ['GET', 'HEAD'].includes(request.method) &&
+ !headers.has('cdn-cache-control') &&
+ !headers.has('netlify-cdn-cache-control')
+ ) {
+ // 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/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/integration/page-router.test.ts b/tests/integration/page-router.test.ts
index eb039eb79d..3c6b624862 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'
@@ -153,3 +153,136 @@ 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')
})
+
+describe('404 caching', () => {
+ describe('default nextjs 404 (no custom 404)', () => {
+ test.todo('not matching dynamic paths')
+ test.todo('matching dynamic path without revalidate')
+ test.todo('matching dynamic path with revalidate')
+ })
+
+ describe('404 without getStaticProps', () => {
+ test.todo('not matching dynamic paths')
+ test.todo('matching dynamic path without revalidate')
+ test.todo('matching dynamic path with revalidate')
+ })
+
+ describe('404 with getStaticProps without revalidate', () => {
+ test('not matching dynamic paths should be cached permanently', async (ctx) => {
+ console.log('[test] not matching dynamic paths')
+
+ await createFixture('page-router-base-path-i18n', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/base/path/not-existing-page',
+ })
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable')
+ })
+ test('matching dynamic path without revalidate should be cached permanently', async (ctx) => {
+ console.log('[test] matching dynamic path without revalidate')
+
+ await createFixture('page-router-base-path-i18n', ctx)
+ await runPlugin(ctx)
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: '/base/path/products/not-found-no-revalidate',
+ })
+
+ 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) => {
+ console.log('[test] matching dynamic path with revalidate')
+
+ 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)
+ // this page was not prerendered, so no STALE case here
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for 600 seconds',
+ ).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) => {
+ console.log('[test] not matching dynamic paths')
+
+ 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.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=300, stale-while-revalidate=31536000, durable')
+ })
+ test.only('matching dynamic path without revalidate should be cached permanently', async (ctx) => {
+ console.log('[test] matching dynamic path without revalidate')
+
+ 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-no-revalidate',
+ // })
+ // await new Promise((res) => setTimeout(res, 100))
+
+ const notExistingPage = await invokeFunction(ctx, {
+ url: 'products/not-found-no-revalidate',
+ })
+
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached permanently',
+ ).toBe('s-maxage=31536000, durable')
+ })
+
+ test.only('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => {
+ console.log('[test] matching dynamic path with revalidate')
+
+ 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)
+ // this page was not prerendered, so no STALE case here
+ expect(
+ notExistingPage.headers['netlify-cdn-cache-control'],
+ 'should be cached for 600 seconds',
+ ).toBe('s-maxage=600, stale-while-revalidate=31535400, durable')
+ })
+ })
+})