diff --git a/crates/next-api/src/next_server_nft.rs b/crates/next-api/src/next_server_nft.rs
index 77b3150415b4..7abd4b326a1f 100644
--- a/crates/next-api/src/next_server_nft.rs
+++ b/crates/next-api/src/next_server_nft.rs
@@ -237,8 +237,7 @@ impl Asset for ServerNftJsonAsset {
.get_relative_path_to(&module_path)
.context("failed to compute relative path for server NFT JSON")?,
module_path
- .read()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ .hash_file(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
.await?,
));
@@ -258,8 +257,7 @@ impl Asset for ServerNftJsonAsset {
base_dir
.get_relative_path_to(file)
.context("failed to compute relative path for server NFT JSON")?,
- file.read()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ file.hash_file(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
.await?,
))
}
diff --git a/crates/next-api/src/nft_json.rs b/crates/next-api/src/nft_json.rs
index 6d003c8adcf5..138c540c56aa 100644
--- a/crates/next-api/src/nft_json.rs
+++ b/crates/next-api/src/nft_json.rs
@@ -241,8 +241,7 @@ impl Asset for NftJsonAsset {
relative_path,
Either::Left(
file_path
- .read()
- .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
+ .hash_file(hash_salt, HashAlgorithm::Xxh3Hash128Hex)
.await?,
),
))
diff --git a/docs/01-app/02-guides/authentication.mdx b/docs/01-app/02-guides/authentication.mdx
index 2715ed476443..4c808308a2f8 100644
--- a/docs/01-app/02-guides/authentication.mdx
+++ b/docs/01-app/02-guides/authentication.mdx
@@ -100,7 +100,7 @@ export async function signup(formData) {}
#### 2. Validate form fields on the server
-Use the Server Action to validate the form fields on the server. If your authentication provider doesn't provide form validation, you can use a schema validation library like [Zod](https://zod.dev/) or [Yup](https://github.com/jquense/yup).
+Use the Server Action to validate the form fields on the server. If your authentication provider doesn't provide form validation, you can use a schema validation library like [Zod](https://zod.dev/), [Valibot](https://valibot.dev/) or [Yup](https://github.com/jquense/yup).
Using Zod as an example, you can define a form schema with appropriate error messages:
diff --git a/docs/01-app/02-guides/forms.mdx b/docs/01-app/02-guides/forms.mdx
index 2a6a6cfacea9..3a2598cb85c2 100644
--- a/docs/01-app/02-guides/forms.mdx
+++ b/docs/01-app/02-guides/forms.mdx
@@ -131,7 +131,7 @@ export async function updateUser(userId, formData) {}
Forms can be validated on the client or server.
- For **client-side validation**, you can use the HTML attributes like `required` and `type="email"` for basic validation.
-- For **server-side validation**, you can use a library like [zod](https://zod.dev/) to validate the form fields. For example:
+- For **server-side validation**, you can use a schema validation library like [Zod](https://zod.dev/) or [Valibot](https://valibot.dev/) to validate the form fields. For example:
```tsx filename="app/actions.ts" switcher
'use server'
diff --git a/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx b/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx
index 10d31e04b02f..f9b8d3752cc9 100644
--- a/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx
+++ b/docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx
@@ -12,8 +12,6 @@ When used in a streaming context, this will insert a meta tag to emit the redire
If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.
-> **Good to know**: If you prefer to return a 307 (Temporary) HTTP redirect instead of 308 (Permanent), you can use the [`redirect` function](/docs/app/api-reference/functions/redirect) instead.
-
## Parameters
The `permanentRedirect` function accepts two arguments:
@@ -45,6 +43,11 @@ The `type` parameter has no effect when used in Server Components.
`permanentRedirect` does not return a value.
+## Behavior
+
+- In Server Actions and Route Handlers, `permanentRedirect` should be called **outside** the `try` block when using `try/catch` statements because it throws an error.
+- If you prefer to return a 307 (Temporary) HTTP redirect instead of 308 (Permanent), you can use the [`redirect` function](/docs/app/api-reference/functions/redirect) instead.
+
## Example
Invoking the `permanentRedirect()` function throws a `NEXT_REDIRECT` error and terminates rendering of the route segment in which it was thrown.
diff --git a/docs/02-pages/02-guides/forms.mdx b/docs/02-pages/02-guides/forms.mdx
index ec6b418143b6..f34247b9a047 100644
--- a/docs/02-pages/02-guides/forms.mdx
+++ b/docs/02-pages/02-guides/forms.mdx
@@ -96,7 +96,7 @@ export default function Page() {
We recommend using HTML validation like `required` and `type="email"` for basic client-side form validation.
-For more advanced server-side validation, you can use a schema validation library like [zod](https://zod.dev/) to validate the form fields before mutating the data:
+For more advanced server-side validation, you can use a schema validation library like [Zod](https://zod.dev/) or [Valibot](https://valibot.dev/) to validate the form fields before mutating the data:
```ts filename="pages/api/submit.ts" switcher
import type { NextApiRequest, NextApiResponse } from 'next'
diff --git a/packages/next/src/build/templates/app-page-runtime.ts b/packages/next/src/build/templates/app-page-runtime.ts
index 9a8844648542..9f55ec77ffb4 100644
--- a/packages/next/src/build/templates/app-page-runtime.ts
+++ b/packages/next/src/build/templates/app-page-runtime.ts
@@ -50,7 +50,6 @@ import {
NEXT_IS_PRERENDER_HEADER,
NEXT_DID_POSTPONE_HEADER,
RSC_CONTENT_TYPE_HEADER,
- NEXT_HMR_REFRESH_HEADER,
} from '../../client/components/app-router-headers' with { 'turbopack-transition': 'next-server-utility' }
import { getBotType } from '../../shared/lib/router/utils/is-bot' with { 'turbopack-transition': 'next-server-utility' }
import {
@@ -1623,21 +1622,14 @@ export function createAppPageEntrypoint({
)
}
- // Dev responses use `no-cache` so the browser can restore them from the
- // HTTP cache on back/forward instead of reloading. HMR refresh responses
- // opt out into `no-store` because a superseded refresh's fetch is aborted
- // mid-write: under `no-cache` the response is stored, so the abort leaves
- // the cache entry shared with the superseding refresh (same URL)
- // half-written; Chromium then discards it and reissues the superseding
- // refresh on a second connection as a duplicate request. `no-store` keeps
- // that entry from being created.
+ // Documents and RSC payloads must not be stored in development.
+ // Browsers reuse a stored response for a history navigation without
+ // revalidating it, so a back navigation would restore a page from
+ // before the latest edit. Static assets never reach this code. They
+ // keep a revalidatable `Cache-Control`, so the browser caches them
+ // between page loads.
if (routeModule.isDev) {
- res.setHeader(
- 'Cache-Control',
- req.headers[NEXT_HMR_REFRESH_HEADER] === '1'
- ? 'no-store'
- : 'no-cache, must-revalidate'
- )
+ res.setHeader('Cache-Control', 'no-store')
}
if (!cacheEntry) {
diff --git a/packages/next/src/client/dev/debug-channel.ts b/packages/next/src/client/dev/debug-channel.ts
index 21016eb21ac5..0791ac23e665 100644
--- a/packages/next/src/client/dev/debug-channel.ts
+++ b/packages/next/src/client/dev/debug-channel.ts
@@ -32,306 +32,6 @@ const pairs = new Map()
*/
const MAX_DEBUG_CHANNEL_PAIRS = 64
-const DB_NAME = '__next_debug_channel'
-const STORE_NAME = 'channels'
-const CREATED_AT_INDEX = 'createdAt'
-/**
- * Upper bound on persisted document debug channels in IndexedDB (one per
- * document, kept for HTTP-cache restore), evicted oldest-first.
- */
-const MAX_PERSISTED_DOCUMENT_CHANNELS = 10
-
-interface DebugChannelEntry {
- readonly requestId: string
- readonly createdAt: number
- readonly chunks: Uint8Array[]
-}
-
-function openDebugChannelDB(): Promise {
- return new Promise((resolve, reject) => {
- const openRequest = indexedDB.open(DB_NAME, 1)
- openRequest.onupgradeneeded = () => {
- const store = openRequest.result.createObjectStore(STORE_NAME, {
- keyPath: 'requestId',
- })
- store.createIndex(CREATED_AT_INDEX, 'createdAt')
- }
- openRequest.onsuccess = () => resolve(openRequest.result)
- openRequest.onerror = () => reject(openRequest.error)
- openRequest.onblocked = () => reject(openRequest.error)
- })
-}
-
-/**
- * Resolves on the next idle period via `requestIdleCallback`, falling back to a
- * `setTimeout` where `requestIdleCallback` is unavailable.
- */
-function whenIdle(): Promise {
- return new Promise((resolve) => {
- if (typeof requestIdleCallback === 'function') {
- requestIdleCallback(() => resolve())
- } else {
- setTimeout(resolve, 0)
- }
- })
-}
-
-async function persistDebugChannelToIndexedDB(
- requestId: string,
- chunks: Uint8Array[]
-): Promise {
- let db: IDBDatabase
- try {
- db = await openDebugChannelDB()
- } catch (error) {
- console.debug('Failed to open debug channel IndexedDB for write', error)
- return
- }
-
- try {
- await new Promise((resolve, reject) => {
- const transaction = db.transaction(STORE_NAME, 'readwrite')
- const store = transaction.objectStore(STORE_NAME)
-
- store.put({
- requestId,
- createdAt: Date.now(),
- chunks,
- } satisfies DebugChannelEntry)
-
- // Prune oldest entries beyond the cap to bound storage growth across tabs
- // and/or page loads. The createdAt index gives ordered traversal without
- // scanning, and the cursor deletes commit atomically with the put above.
- const countReq = store.count()
- countReq.onsuccess = () => {
- let entriesToDelete = countReq.result - MAX_PERSISTED_DOCUMENT_CHANNELS
- if (entriesToDelete <= 0) {
- return
- }
- const cursorReq = store.index(CREATED_AT_INDEX).openCursor()
- cursorReq.onsuccess = () => {
- const cursor = cursorReq.result
- if (!cursor || entriesToDelete === 0) {
- return
- }
- cursor.delete()
- entriesToDelete--
- cursor.continue()
- }
- }
-
- transaction.oncomplete = () => {
- if (process.env.__NEXT_TEST_MODE) {
- // Test-only flag, set once this document's debug channel entry is
- // durably committed. Persistence is deferred to an idle callback and
- // the IndexedDB write is async, so this flag lets e2e tests await
- // persistence deterministically — coupling only to "an entry was
- // persisted" and not to how or where it is stored. It resets
- // naturally on each navigation since every document gets a fresh
- // window. The local cast keeps the augmentation out of the shipped
- // declaration files.
- ;(
- self as { __NEXT_DEBUG_CHANNEL_PERSISTED?: boolean }
- ).__NEXT_DEBUG_CHANNEL_PERSISTED = true
- }
- resolve()
- }
- transaction.onerror = () => reject(transaction.error)
- transaction.onabort = () => reject(transaction.error)
- })
- } catch (error) {
- // Best-effort: if persistence fails (quota, transaction abort, etc.), an
- // HTTP cache restore will fall back to location.reload() since no entry
- // will be found.
- console.debug('Failed to write debug channel entry to IndexedDB', error)
- } finally {
- db.close()
- }
-}
-function restoreDebugChannelFromIndexedDB(
- requestId: string
-): ReadableStream {
- return new ReadableStream({
- async start(controller) {
- let entry: DebugChannelEntry | undefined
-
- try {
- const db = await openDebugChannelDB()
- try {
- entry = await new Promise((resolve, reject) => {
- const tx = db.transaction(STORE_NAME, 'readonly')
- const store = tx.objectStore(STORE_NAME)
- const getReq: IDBRequest =
- store.get(requestId)
- getReq.onsuccess = () => resolve(getReq.result)
- getReq.onerror = () => reject(getReq.error)
- })
- } finally {
- db.close()
- }
- } catch (error) {
- // Treat any IDB failure as "no entry" and fall through to reload.
- console.debug(
- 'Failed to read debug channel entry from IndexedDB',
- error
- )
- }
-
- if (!entry) {
- // Debug channel can't be restored — missing debug chunks would block
- // hydration. Force a fresh page load from the server. Leave the stream
- // parked (no enqueue, no close) so the Flight client stays put until
- // the reload tears the document down, instead of synchronously erroring
- // with "Connection closed.".
- location.reload()
- return
- }
-
- for (const chunk of entry.chunks) {
- controller.enqueue(chunk)
- }
- controller.close()
- },
- })
-}
-
-const enum ExecTimeCacheDecision {
- /**
- * The HTML document was served from the browser's cache; replay the
- * previously persisted chunks instead of waiting for the WebSocket-backed
- * channel.
- */
- CacheRestore,
-
- /**
- * The HTML document came fresh from the server. The live WebSocket-backed
- * channel will deliver the debug chunks.
- */
- FreshResponse,
-
- /**
- * Can't tell from the navigation entry as it stands now. Caller should defer
- * to `pageshow` and re-check there with `wasServedFromCacheAtPageshow`.
- */
- Undecided,
-}
-
-/**
- * Decide at script-execution time whether the document was served from the
- * browser's cache or freshly fetched from the server. `type === 'back_forward'`
- * alone isn't enough: a back/forward navigation can also be a fresh server
- * re-fetch when the HTTP cache entry was evicted (long-lived tab, storage
- * pressure, manual cache clear), and treating that as a cache restore would
- * trigger an unnecessary `location.reload()` when no persisted chunks are
- * found.
- */
-function wasServedFromCacheKnownAtExec(
- entry: NavigationEntry | undefined
-): ExecTimeCacheDecision {
- if (!entry) {
- return ExecTimeCacheDecision.FreshResponse
- }
-
- // Safari tab-duplication cache restore: type='navigate' paired with
- // responseStart=0 (no first-body-byte over the network) and a non-zero
- // responseEnd. Fresh navigations always have responseStart > 0.
- if (
- entry.type === 'navigate' &&
- entry.responseStart === 0 &&
- entry.responseEnd > 0
- ) {
- return ExecTimeCacheDecision.CacheRestore
- }
-
- // Every remaining cache-restore signal requires a back/forward navigation.
- // (bfcache restores don't re-execute scripts and never reach this code.)
- if (entry.type !== 'back_forward') {
- return ExecTimeCacheDecision.FreshResponse
- }
-
- // Chrome ≥109 and Safari ≥17 populate `deliveryType` at exec time even when
- // the size fields aren't filled in yet. This is the only exec-time fast path
- // for real Safari ≥17 cache restores (Safari leaves encodedBodySize at 0 at
- // exec).
- if (entry.deliveryType === 'cache') {
- return ExecTimeCacheDecision.CacheRestore
- }
-
- // Chrome and Firefox publish an HTTP cache restore as transferSize=0 (no
- // bytes over the wire) plus a non-zero cached body size at exec time.
- if (entry.transferSize === 0 && entry.encodedBodySize > 0) {
- return ExecTimeCacheDecision.CacheRestore
- }
-
- // No body bytes measured yet. Either the response is still streaming, or
- // WebKit is reporting transferSize=0 and encodedBodySize=0 at exec time
- // regardless of whether the document was cached or re-fetched. Defer to
- // `pageshow` where the two cases become distinguishable.
- if (entry.encodedBodySize === 0) {
- return ExecTimeCacheDecision.Undecided
- }
-
- // Body bytes already measured at exec time with no other cache signal: a
- // re-fetched back-nav whose response happened to complete before our script
- // ran. The deferred branch above would have caught the same case if the
- // response had still been streaming.
- return ExecTimeCacheDecision.FreshResponse
-}
-
-/**
- * Re-check the cache-restore decision at `pageshow`, when every browser has
- * populated the navigation-entry size fields. Only called when
- * `wasServedFromCacheKnownAtExec` returned `ExecTimeCacheDecision.Undecided`.
- */
-function wasServedFromCacheAtPageshow(
- entry: NavigationEntry | undefined
-): boolean {
- if (!entry) {
- return false
- }
-
- // Safari tab-duplication signature; see the matching branch in
- // `wasServedFromCacheKnownAtExec`.
- if (
- entry.type === 'navigate' &&
- entry.responseStart === 0 &&
- entry.responseEnd > 0
- ) {
- return true
- }
-
- // A back/forward navigation where at least one of the size fields is zero
- // means the body didn't come over the wire. Browsers signal a cache restore
- // differently — Chrome/Firefox zero `transferSize` and keep a non-zero cached
- // `encodedBodySize`; Safari does the inverse with a small `transferSize`
- // (header overhead) and `encodedBodySize=0`; WebKit under Playwright zeros
- // both. A fresh re-fetch populates both with the response size.
- return (
- entry.type === 'back_forward' &&
- (entry.transferSize === 0 || entry.encodedBodySize === 0)
- )
-}
-
-/**
- * The DOM lib's `PerformanceNavigationTiming` doesn't include the
- * `deliveryType` property yet, even though it's shipped in Chrome ≥109,
- * Firefox ≥115, and Safari ≥17. See
- * https://w3c.github.io/navigation-timing/#dom-performancenavigationtiming-deliverytype.
- */
-type NavigationEntry = PerformanceNavigationTiming & {
- readonly deliveryType?: string
-}
-
-function getNavigationEntry(): NavigationEntry | undefined {
- try {
- return performance.getEntriesByType('navigation')[0] as
- | NavigationEntry
- | undefined
- } catch {
- return undefined
- }
-}
-
/**
* Reclaim the least-recently-used debug-channel pairs once the map exceeds
* `MAX_DEBUG_CHANNEL_PAIRS`. The map is iterated in insertion order and we
@@ -365,20 +65,7 @@ export function getOrCreateDebugChannelReadableWriterPair(
return existingPair
}
- // Buffer chunks only for the initial document's debug channel, not for
- // client-side navigation requests. Persisted to IndexedDB once complete so it
- // can be restored when the browser serves the page from HTTP cache
- // (back-forward navigation, tab duplication, etc.).
- const chunks: Uint8Array[] | null = requestId === self.__next_r ? [] : null
-
- const { readable, writable } = new TransformStream({
- transform(chunk, controller) {
- if (chunks) {
- chunks.push(chunk.slice())
- }
- controller.enqueue(chunk)
- },
- })
+ const { readable, writable } = new TransformStream()
const pair: DebugChannelReadableWriterPair = {
readable,
@@ -389,31 +76,11 @@ export function getOrCreateDebugChannelReadableWriterPair(
// bound the map by reclaiming the least-recently-used.
evictExcessDebugChannelPairs()
- pair.writer.closed
- .then(async () => {
- if (!chunks) {
- return
- }
- // The initial document's debug stream closes while hydration is still
- // running, so persisting here would steal main-thread time from it. Wait
- // for genuine idle (no timeout): persistence is best-effort, so if the
- // page never idles before navigation we skip it and a later restore falls
- // back to a reload, rather than forcing a blocking write.
- await whenIdle()
- await persistDebugChannelToIndexedDB(requestId, chunks)
- })
- .catch((error) => {
- // writer.closed rejected (e.g., stream aborted), nothing to persist.
- console.debug('Debug channel writer closed with error', error)
- })
- .finally(() => {
- // Keep the now-closed pair in the map so late decodes of this request
- // still resolve against its buffered stream; it's reclaimed later by LRU
- // eviction. Release the IndexedDB staging buffer now that it's persisted.
- if (chunks) {
- chunks.length = 0
- }
- })
+ // An errored stream rejects `writer.closed`. Observe the rejection so that it
+ // does not surface as an unhandled rejection.
+ pair.writer.closed.catch((error) => {
+ console.debug('Debug channel writer closed with error', error)
+ })
return pair
}
@@ -444,24 +111,6 @@ export function createDebugChannel(
}
}
- // Only attempt to restore the IndexedDB debug channel entry for the
- // initial document load (no request headers). Client-side navigations pass
- // request headers and should always use the WebSocket-backed debug channel.
- if (!requestHeaders) {
- switch (wasServedFromCacheKnownAtExec(getNavigationEntry())) {
- case ExecTimeCacheDecision.CacheRestore:
- return { readable: restoreDebugChannelOrReload(requestId) }
- case ExecTimeCacheDecision.Undecided:
- // Body bytes haven't been measured on the navigation entry yet. Suspend
- // the stream until pageshow, re-check there, then source from the
- // persisted chunks or the WebSocket-backed pair accordingly.
- return { readable: createDeferredDebugChannelReadable(requestId) }
- case ExecTimeCacheDecision.FreshResponse:
- // Fall through to the shared WebSocket-backed channel below.
- break
- }
- }
-
const pair = getOrCreateDebugChannelReadableWriterPair(requestId)
// Hand out a fresh tee branch per consumer and keep the remainder for the
// next one (see the `readable` field doc above).
@@ -470,66 +119,3 @@ export function createDebugChannel(
return { readable: branch }
}
-
-/**
- * Try to restore the debug channel from the persisted chunks. If none are
- * found, force a fresh page load.
- */
-function restoreDebugChannelOrReload(
- requestId: string
-): ReadableStream {
- const readable = restoreDebugChannelFromIndexedDB(requestId)
-
- if (readable) {
- return readable
- }
-
- // No persisted entry. Typically this happens when the HTTP cache held the
- // HTML but the persisted entry was never written, or was overwritten by a
- // newer document in this tab.
- location.reload()
-
- // Never-closing stream. Keeps the Flight client suspended until the reload
- // tears the document down, instead of letting it synchronously error with
- // "Connection closed.".
- return new ReadableStream()
-}
-
-/**
- * Used when `wasServedFromCacheKnownAtExec` returns
- * `ExecTimeCacheDecision.Undecided`. Waits for `pageshow`, re-runs the check,
- * and forwards data from either the persisted chunks or the WebSocket.
- */
-function createDeferredDebugChannelReadable(
- requestId: string
-): ReadableStream {
- return new ReadableStream({
- async start(controller) {
- // By `pageshow` every browser has populated the navigation-entry size
- // fields, so the re-check below is unambiguous.
- await new Promise((resolve) => {
- window.addEventListener('pageshow', () => resolve(), { once: true })
- })
-
- const source = wasServedFromCacheAtPageshow(getNavigationEntry())
- ? restoreDebugChannelOrReload(requestId)
- : getOrCreateDebugChannelReadableWriterPair(requestId).readable
-
- const reader = source.getReader()
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) {
- controller.close()
- return
- }
- controller.enqueue(value)
- }
- } catch (error) {
- controller.error(error)
- } finally {
- reader.releaseLock()
- }
- },
- })
-}
diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts
index 60bb9fb11054..9f333b6173f6 100644
--- a/packages/next/src/server/base-server.ts
+++ b/packages/next/src/server/base-server.ts
@@ -102,7 +102,6 @@ import {
NEXT_URL,
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_INSTANT_TEST_COOKIE,
- NEXT_HMR_REFRESH_HEADER,
} from '../client/components/app-router-headers'
import { nanoid } from 'next/dist/compiled/nanoid'
import { LocaleRouteNormalizer } from './normalizers/locale-route-normalizer'
@@ -2140,21 +2139,14 @@ export default abstract class Server<
if (!res.sent) {
const { generateEtags, poweredByHeader } = this.renderOpts
- // Dev responses use `no-cache` so the browser can restore them from the
- // HTTP cache on back/forward instead of reloading. HMR refresh responses
- // opt out into `no-store` because a superseded refresh's fetch is aborted
- // mid-write: under `no-cache` the response is stored, so the abort leaves
- // the cache entry shared with the superseding refresh (same URL)
- // half-written; Chromium then discards it and reissues the superseding
- // refresh on a second connection as a duplicate request. `no-store` keeps
- // that entry from being created.
+ // Documents and data responses must not be stored in development.
+ // Browsers reuse a stored response for a history navigation without
+ // revalidating it, so a back navigation would restore a page from before
+ // the latest edit. Static assets never reach this code. They keep a
+ // revalidatable `Cache-Control`, so the browser caches them between page
+ // loads.
if (this.dev) {
- res.setHeader(
- 'Cache-Control',
- req.headers[NEXT_HMR_REFRESH_HEADER] === '1'
- ? 'no-store'
- : 'no-cache, must-revalidate'
- )
+ res.setHeader('Cache-Control', 'no-store')
cacheControl = undefined
}
diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts
index 45a6967134de..031f0ab32400 100644
--- a/packages/next/src/server/lib/router-server.ts
+++ b/packages/next/src/server/lib/router-server.ts
@@ -638,6 +638,10 @@ export async function initialize(opts: {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
res.setHeader('Service-Worker-Allowed', config.basePath || '/')
} else if (opts.dev && !isNextFont(parsedUrl.pathname)) {
+ // Development assets stay revalidatable. `serveStatic` adds an
+ // `ETag`, so the browser sends a conditional request and reuses the
+ // stored body when the server answers `304`. This keeps the browser
+ // from downloading every chunk again on each page load.
res.setHeader('Cache-Control', 'no-cache, must-revalidate')
} else {
res.setHeader(
diff --git a/packages/next/src/server/lib/router-utils/filesystem.ts b/packages/next/src/server/lib/router-utils/filesystem.ts
index 874ebae1e915..986b85c34f47 100644
--- a/packages/next/src/server/lib/router-utils/filesystem.ts
+++ b/packages/next/src/server/lib/router-utils/filesystem.ts
@@ -743,7 +743,8 @@ export async function setupFsCheck(opts: {
const fsPath = staticMetadataFiles.get(itemPath)
if (fsPath) {
return {
- // "nextStaticFolder" sets Cache-Control "no-store" on dev.
+ // "nextStaticFolder" sets Cache-Control
+ // "no-cache, must-revalidate" on dev.
type: 'nextStaticFolder',
fsPath,
itemPath: fsPath,
diff --git a/packages/next/src/server/route-modules/pages/pages-handler.ts b/packages/next/src/server/route-modules/pages/pages-handler.ts
index 627c5747e672..2d939b16438c 100644
--- a/packages/next/src/server/route-modules/pages/pages-handler.ts
+++ b/packages/next/src/server/route-modules/pages/pages-handler.ts
@@ -687,9 +687,14 @@ export const getHandler = ({
)
}
- // In dev, we should not cache pages for any reason.
+ // Documents and data responses must not be stored in development.
+ // Browsers reuse a stored response for a history navigation without
+ // revalidating it, so a back navigation would restore a page from
+ // before the latest edit. Static assets never reach this code. They
+ // keep a revalidatable `Cache-Control`, so the browser caches them
+ // between page loads.
if (routeModule.isDev) {
- res.setHeader('Cache-Control', 'no-cache, must-revalidate')
+ res.setHeader('Cache-Control', 'no-store')
}
// Draft mode should never be cached
diff --git a/test/development/dev-cache-control-no-cache/app/app-route/page.js b/test/development/dev-cache-control-no-cache/app/app-route/page.js
deleted file mode 100644
index cabb5263b521..000000000000
--- a/test/development/dev-cache-control-no-cache/app/app-route/page.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function AppRoute() {
- return
+}
diff --git a/test/development/dev-cache-control/app/layout.tsx b/test/development/dev-cache-control/app/layout.tsx
new file mode 100644
index 000000000000..7c3f422f0039
--- /dev/null
+++ b/test/development/dev-cache-control/app/layout.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from 'react'
+
+export default function Root({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/development/dev-cache-control/app/page.tsx b/test/development/dev-cache-control/app/page.tsx
new file mode 100644
index 000000000000..37b1c82497b8
--- /dev/null
+++ b/test/development/dev-cache-control/app/page.tsx
@@ -0,0 +1,12 @@
+import { value } from './value'
+
+export default function Page() {
+ return (
+ <>
+
{value}
+
+ About
+
+ >
+ )
+}
diff --git a/test/development/dev-cache-control/app/value.ts b/test/development/dev-cache-control/app/value.ts
new file mode 100644
index 000000000000..af0f2f03fe2f
--- /dev/null
+++ b/test/development/dev-cache-control/app/value.ts
@@ -0,0 +1 @@
+export const value = 'Value A'
diff --git a/test/development/dev-cache-control/dev-cache-control.test.ts b/test/development/dev-cache-control/dev-cache-control.test.ts
new file mode 100644
index 000000000000..de4a40bb9420
--- /dev/null
+++ b/test/development/dev-cache-control/dev-cache-control.test.ts
@@ -0,0 +1,81 @@
+import type * as Playwright from 'playwright'
+import { nextTestSetup } from 'e2e-utils'
+import { retry } from 'next-test-utils'
+import { readFile, writeFile } from 'fs/promises'
+import { join } from 'path'
+
+describe('dev Cache-Control', () => {
+ const { next } = nextTestSetup({
+ files: __dirname,
+ })
+
+ it('sends no-store for an app router document', async () => {
+ const res = await next.fetch('/')
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ })
+
+ it('sends no-store for a pages router document', async () => {
+ const res = await next.fetch('/pages-route')
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ })
+
+ it('keeps serving static assets from the browser cache', async () => {
+ const browser = await next.browser('/')
+ const assetStatusCodes: number[] = []
+
+ browser.on('response', (response: Playwright.Response) => {
+ const url = new URL(response.url())
+
+ // The webpack dev bundler adds a `v` query to some of its own chunks to
+ // bust the browser cache on every page load. Those are never cache hits
+ // by design.
+ if (
+ url.pathname.startsWith('/_next/static/') &&
+ !url.searchParams.has('v')
+ ) {
+ assetStatusCodes.push(response.status())
+ }
+ })
+
+ // Only the responses of the second page load are of interest.
+ assetStatusCodes.length = 0
+ await browser.refresh()
+
+ await retry(async () => {
+ expect(assetStatusCodes.length).toBeGreaterThan(0)
+ })
+
+ // The dev server answers the revalidation of an unchanged asset with 304,
+ // so the browser reuses the body from its cache instead of downloading it
+ // again. `no-store` would force a full download on every page load.
+ expect([...new Set(assetStatusCodes)]).toEqual([304])
+ })
+
+ // Runs last because it edits a file that the other test cases rely on.
+ it('serves an edited page after a back navigation', async () => {
+ const browser = await next.browser('/')
+ expect(await browser.elementByCss('#value').text()).toBe('Value A')
+
+ // A plain anchor triggers a document navigation, so the browser can keep
+ // the page it navigates away from in its HTTP cache.
+ await browser.elementByCss('#to-about').click()
+ await browser.waitForElementByCss('#about')
+
+ const valueFile = join(next.testDir, 'app/value.ts')
+ const value = await readFile(valueFile, 'utf8')
+ await writeFile(valueFile, value.replace('Value A', 'Value B'))
+
+ // The dev server must serve the edited value before going back, so that a
+ // stale page can only come from the browser.
+ await retry(async () => {
+ const $ = await next.render$('/')
+ expect($('#value').text()).toBe('Value B')
+ })
+
+ await browser.back({ waitUntil: 'commit' })
+
+ await retry(async () => {
+ expect(await browser.elementByCss('#value').text()).toBe('Value B')
+ })
+ })
+})
diff --git a/test/development/dev-cache-control/next.config.js b/test/development/dev-cache-control/next.config.js
new file mode 100644
index 000000000000..3e88313f550b
--- /dev/null
+++ b/test/development/dev-cache-control/next.config.js
@@ -0,0 +1,9 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ // Writing the agent rules files would trigger an unrelated Fast Refresh.
+ agentRules: false,
+}
+
+module.exports = nextConfig
diff --git a/test/development/dev-cache-control-no-cache/pages/pages-route.js b/test/development/dev-cache-control/pages/pages-route.js
similarity index 100%
rename from test/development/dev-cache-control-no-cache/pages/pages-route.js
rename to test/development/dev-cache-control/pages/pages-route.js
diff --git a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx b/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
deleted file mode 100644
index 9047d5ebad94..000000000000
--- a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/client.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-
-export function ClientComponent() {
- const [count, setCount] = useState(0)
-
- return
-}
diff --git a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx b/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
deleted file mode 100644
index f97094b0e466..000000000000
--- a/test/e2e/app-dir/bfcache-regression/app/large-debug-data/page.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Suspense } from 'react'
-import { ClientComponent } from './client'
-
-// This page is only for manual performance profiling of the debug channel
-// persistence (it streams a large amount of debug data). It is not used by any
-// end-to-end test.
-async function Home() {
- for (let i = 0; i < 50; i++) {
- await new Promise((resolve) =>
- setTimeout(() => resolve('a'.repeat(1_000_000)))
- )
- }
-
- return (
-