nextjs - ✅(Solved) Fix SWR Cache-Control disabled after Next.js 15.5 when using a rewrite middleware [1 pull requests, 6 comments, 6 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#83862Fetched 2026-04-08 02:21:20
View on GitHub
Comments
6
Participants
6
Timeline
20
Reactions
1
Timeline (top)
subscribed ×7commented ×6cross-referenced ×2labeled ×2

Fix Action

Fixed

PR fix notes

PR #610: Introduce cacheComponents to Cache CMS Calls

Description (problem / solution / changelog)

Currently, all pages are rendered dynamically, i.e. NextJS re-renders them on every request, resulting in one or multiple requests to PayloadCMS (database lookups) for every page visit. This is unnecessary, as the content of our pages is mostly static.

This per enables the NextJS 16 cacheComponents feature, which can be used to cache expensive CMS calls between requests. To maximise static pre-rendered content, one must avoid accessing user-specific data while rendering a request, i.e. we can no longer access the cookies and/or queryParameters of the request. Thus, this PR introduces a significant change to how we render our pages in app/web mode as well as to the draft mode.

Web Mode versus App Mode

Before we checked on every request if the page should be rendered in app mode or in web mode using the cookie. Now we introduce a NextJS middleware (proxy as of NextJS 16), which rewrites the URL internally.

For example: /some-page-slug gets mapped internally to /[locale]/[design-mode]/some-page-slug where [locale] is a placeholder for one of our locales (de, en, fr). [design-mode] is a placeholder for one of our two design modes (design-mode-web or design-mode-app). This rewrite can be verified by checking the x-middleware-rewrite header of the response.

This allows us to generate six versions per page and cache them statically. Namlly, all combinations between locales and design modes.

Preview Mode

Before we checked if the page should be rendered in preview mode by accessing the queryParameters and cookies of the request, these variables are user-specific and automatically cause NextJS to opt out of static rendering. Thus, we can no longer rely on those checks to determine if a page should be rendered in preview mode or not. However, NextJS offer a feature called draftMode which can be used instead. draftMode bypasses the static rendering, forcing the page to be rendered dynamically if enabled. This PR introduces the necessary changes to use draftMode instead of checking the cookies directly.

Preview mode can be enabled using a call to /api/draft. This happens in one of two cases:

  1. If you view the PayloadCMS's admin panel, we enable preview mode globally until it is exited manually (for the current session). This happens via a call to /api/draft?auth=session&slug=*.
  2. If you create and share a preview link. This link works as a redirect which first activates draft mode, then redirects the user to the requested slug. In that case, a call to /api/draft?auth=secret&preview-token=<current-secret-token>&preview=true&slug=/the/slug/you/want/to/visit.

In either case, we do a double permission check, once while enabling the draft mode during the execution of the /api/draft route (this is done to prevent a malicious user from bypassing static rendering) and once while rendering the actual page (i.e. before fetching the document in draft / published state, we re-check if the user is allowed to visit the current page in preview mode.

Invalidate Cache on Changes

When changes are made in the admin panel, we flush the page cache to ensure that saved and published changes are eventually reflected in the publicly visible page.

Open Todos:

  • update Readme about dynamic rendering, it's outdated / uses the terminology wrong
  • invalidate caches on changes (multi-instances)
  • cache is not shared between instances
  • search is broken
  • preview mode URL check is broken (sharing of preview link for a non-landingpage, probably due to internal rewrites)
  • preview mode banner is not rendered correctly
  • Language switching results in unwanted URL patterns (e.g., /de//some-slug should be /some-slug, not /de/some-slug and not /de//some-slug)
  • Page Preview does not work as expected

Things we Should Test before Merging this PR (@wp99cp)

  • check if generic pages get rendered correctly
  • check if blog pages get rendered correctly
  • verify that generic pages get rendered statically
  • verify that blog pages get rendered statically
  • verify preview of generic pages works in PayloadCMS's admin panel
  • verify preview of blog pages works in PayloadCMS's admin panel
  • verify preview of timeline entries works in PayloadCMS's admin panel
  • verify that a preview link can be shared, which only gives preview access to that particular generic page
  • verify that a preview link can be shared, which only gives preview access to that particular blog post
  • verify that the preview banner is correctly rendered once we have visited the admin panel
  • verify that the page gets re-rendered once a change is published (cache invalidation) (generic page)
  • verify that the page gets re-rendered once a change is published (cache invalidation) (blog page)
  • verify that the preview of the main menu works as expected
  • verify that search works as expected
  • verify that 404 errors get handled correctly
  • verify that generic pages with access restrictions respect those restrictions
  • verify that generic pages with access restrictions are never rendered statically
  • verify that blog pages with access restrictions respect those restrictions
  • verify that blog pages with access restrictions are never rendered statically
  • check if a signed-in user without access to the admin panel cannot enable draft mode
  • check if cache flushes correctly

Things we Should Test before Merging this PR (@maede97)

  • check if generic pages get rendered correctly
  • check if blog pages get rendered correctly
  • verify that generic pages get rendered statically
  • verify that blog pages get rendered statically
  • verify preview of generic pages works in PayloadCMS's admin panel
  • verify preview of blog pages works in PayloadCMS's admin panel
  • verify preview of timeline entries works in PayloadCMS's admin panel
  • verify that a preview link can be shared, which only gives preview access to that particular generic page
  • verify that a preview link can be shared, which only gives preview access to that particular blog post
  • verify that the preview banner is correctly rendered once we have visited the admin panel
  • verify that the page gets re-rendered once a change is published (cache invalidation) (generic page)
  • verify that the page gets re-rendered once a change is published (cache invalidation) (blog page)
  • verify that the preview of the main menu works as expected
  • verify that search works as expected
  • verify that 404 errors get handled correctly
  • verify that generic pages with access restrictions respect those restrictions
  • verify that generic pages with access restrictions are never rendered statically
  • verify that blog pages with access restrictions respect those restrictions
  • verify that blog pages with access restrictions are never rendered statically
  • check if a signed-in user without access to the admin panel cannot enable draft mode
  • check if cache flushes correctly

Changed files

  • .env.example (modified, +2/-0)
  • .github/workflows/test.yml (added, +34/-0)
  • .gitignore (modified, +6/-1)
  • .prettierignore (modified, +4/-0)
  • README.md (modified, +5/-3)
  • dev-oauth/fake_oauth.py (modified, +31/-3)
  • docker-compose.dev.yml (modified, +8/-0)
  • docker-compose.prod.yml (modified, +13/-0)
  • docker-compose.yml (modified, +13/-15)
  • eslint.config.mjs (modified, +8/-0)
  • jest.config.ts (added, +20/-0)
  • jest.setup.ts (added, +1/-0)
  • next.config.ts (modified, +10/-5)
  • package.json (modified, +68/-53)
  • pnpm-lock.yaml (modified, +4682/-1973)
  • prisma.config.ts (added, +14/-0)
  • prisma/schema.prisma (modified, +6/-1)
  • src/app/(frontend)/(not-found)/[...not-found]/page.ts (renamed, +0/-0)
  • src/app/(frontend)/(not-found)/layout.tsx (renamed, +15/-17)
  • src/app/(frontend)/(not-found)/loading.tsx (added, +13/-0)
  • src/app/(frontend)/(not-found)/not-found.tsx (renamed, +6/-2)
  • src/app/(frontend)/[locale]/(app-pages)/app/chat/[chatId]/details/page.tsx (removed, +0/-8)
  • src/app/(frontend)/[locale]/(app-pages)/app/chat/new/page.tsx (removed, +0/-8)
  • src/app/(frontend)/[locale]/(app-pages)/layout.tsx (removed, +0/-31)
  • src/app/(frontend)/[locale]/(payload-pages)/layout.tsx (removed, +0/-23)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/[chatId]/details/loading.tsx (added, +8/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/[chatId]/details/page.tsx (added, +22/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/[chatId]/layout.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/[chatId]/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/layout.tsx (renamed, +16/-2)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/new-chat-with-user/[userId]/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/new/loading.tsx (added, +8/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/new/page.tsx (added, +22/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/chat/page.tsx (renamed, +1/-1)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/dashboard/page.tsx (renamed, +7/-5)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/department-helper-portal/page.tsx (renamed, +2/-5)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/emergency/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/helper-portal/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/map/page.tsx (renamed, +8/-5)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/map/search/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/schedule/[id]/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/schedule/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/settings/page.tsx (renamed, +3/-4)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/app/upload-images/page.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/error.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/layout.tsx (added, +47/-0)
  • src/app/(frontend)/[locale]/[design]/(app-pages)/loading.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/[[...slugs]]/custom-error-boundary-fallback.tsx (renamed, +2/-2)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/[[...slugs]]/layout.tsx (renamed, +7/-4)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/[[...slugs]]/loading.tsx (added, +38/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/[[...slugs]]/page.tsx (renamed, +41/-38)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/[[...slugs]]/permission-error.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/[[...slugs]]/preview-error.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/go/[[...slugs]]/page.ts (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/go/layout.tsx (added, +21/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/layout.tsx (added, +36/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/loading.tsx (renamed, +0/-0)
  • src/app/(frontend)/[locale]/[design]/(payload-pages)/offline/page.tsx (renamed, +0/-2)
  • src/app/(frontend)/[locale]/[design]/layout.tsx (added, +78/-0)
  • src/app/(frontend)/api/draft/route.ts (modified, +83/-2)
  • src/app/(frontend)/api/trpc/[trpc]/route.ts (modified, +0/-3)
  • src/app/(onboarding)/layout.tsx (modified, +11/-2)
  • src/app/(onboarding)/loading.tsx (added, +13/-0)
  • src/app/(payload)/admin/importMap.js (modified, +3/-1)
  • src/app/(payload)/admin/logout/page.tsx (modified, +13/-4)
  • src/app/(payload)/api/flush-cache/route.ts (added, +37/-0)
  • src/app/(payload)/layout.tsx (modified, +10/-6)
  • src/app/(payload)/loading.tsx (added, +15/-0)
  • src/app/manifest.ts (modified, +6/-13)
  • src/app/sitemap.ts (modified, +4/-11)
  • src/cache-handlers/default.cts (added, +1/-0)
  • src/cache-handlers/redis.cts (added, +193/-0)
  • src/cache-handlers/types.cts (added, +80/-0)
  • src/components/app-advertisement.tsx (modified, +4/-5)
  • src/components/footer/footer-client-wrapper.tsx (added, +17/-0)
  • src/components/footer/footer-component.tsx (modified, +9/-8)
  • src/components/footer/footer-copyright-area.tsx (modified, +30/-20)
  • src/components/footer/hide-footer-context.tsx (added, +43/-0)
  • src/components/footer/social-media-links.tsx (modified, +7/-3)
  • src/components/header/header-client-wrapper.tsx (added, +19/-0)
  • src/components/header/header-component.tsx (modified, +17/-22)
  • src/components/header/hide-header-context.tsx (added, +43/-0)
  • src/components/header/preview-mode-banner-server.tsx (added, +40/-0)
  • src/components/header/preview-mode-banner.tsx (modified, +8/-12)
  • src/components/menu/main-menu-language-switcher.tsx (modified, +11/-2)
  • src/components/menu/main-menu.tsx (modified, +35/-20)
  • src/components/news-card.tsx (modified, +4/-4)
  • src/components/preview-warning-client.tsx (modified, +7/-5)
  • src/components/ui/alert-dialog.tsx (added, +127/-0)
  • src/components/ui/buttons/button.tsx (modified, +1/-2)
  • src/components/ui/search-bar.tsx (modified, +17/-5)
  • src/components/utils/cookie-banner.tsx (modified, +14/-3)
  • src/features/chat/api/chat-router.ts (modified, +4/-0)
  • src/features/chat/api/mutations/update-message-content.ts (added, +113/-0)
  • src/features/chat/api/queries/get-chat-messages.ts (added, +79/-0)
  • src/features/chat/api/queries/get-chat.ts (modified, +3/-2)
  • src/features/chat/api/queries/list-chats.ts (modified, +16/-0)
  • src/features/chat/components/chat-details-view/delete-chat.tsx (modified, +111/-26)
  • src/features/chat/components/chat-overview-view/chat-preview.tsx (modified, +22/-61)
  • src/features/chat/components/chat-overview-view/chats-overview-client-component.tsx (modified, +12/-7)

Code Example

Operating System:
  Platform: linux
  Arch: x64
  Version: #1 SMP PREEMPT_DYNAMIC Sat Aug  2 11:37:34 UTC 2025
  Available memory (MB): 92193
  Available CPU cores: 32
Binaries:
  Node: 20.19.4
  npm: 10.8.2
  Yarn: 1.22.22
  pnpm: N/A
Relevant Packages:
  next: 15.5.3 // Latest available version is detected (15.5.3).
  eslint-config-next: N/A
  react: 19.1.0
  react-dom: 19.1.0
  typescript: 5.9.2
Next.js Config:
  output: N/A
RAW_BUFFERClick to expand / collapse

Link to the code that reproduces this issue

https://github.com/stephenliang/missing-revalidate-rewrite

To Reproduce

  1. Run npm run build to build the application.
  2. Run npm run start to start the production server.
  3. Make a request to http://abc.localhost:3000
  4. Observe the Cache-Control response header.

Current vs. Expected behavior

Expected: s-maxage=3600, stale-while-revalidate=31532400 Actual: private, no-cache, no-store, max-age=0, must-revalidate

Provide environment information

Operating System:
  Platform: linux
  Arch: x64
  Version: #1 SMP PREEMPT_DYNAMIC Sat Aug  2 11:37:34 UTC 2025
  Available memory (MB): 92193
  Available CPU cores: 32
Binaries:
  Node: 20.19.4
  npm: 10.8.2
  Yarn: 1.22.22
  pnpm: N/A
Relevant Packages:
  next: 15.5.3 // Latest available version is detected (15.5.3).
  eslint-config-next: N/A
  react: 19.1.0
  react-dom: 19.1.0
  typescript: 5.9.2
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

Dynamic Routes, cacheComponents, Route Handlers, Partial Prerendering (PPR)

Which stage(s) are affected? (Select all that apply)

next start (local), Other (Deployed)

Additional context

Note: This is likely reproducible on the Next.js Multi-tenant Platforms example since this uses the middleware rewrite methodology.

The first canary version that fails is 15.4.2-canary.2. Using canary 15.4.2-canary.1 works as expected. Additionally, 15.4.7 works as expected and is possibly related to #81321. While debugging, I found that the prerender manifest matcher will use the dynamic route regex from the routes manifest. However, this regex will fail if the path was rewritten.

For example, if the path was /[subdomain]/[locale]/[[...rest]] and the incoming page path was /en-US, then it would fail the regex since the rewrite drops the page subdomain portion.

extent analysis

TL;DR

  • The issue can be mitigated by using a version of Next.js where the prerender manifest matcher correctly handles rewritten paths, such as version 15.4.7.

Guidance

  • Verify that the issue is indeed related to the prerender manifest matcher by checking if the problem persists when using a version of Next.js known to work, such as 15.4.7.
  • Check the routes manifest to ensure that the dynamic route regex correctly accounts for rewritten paths.
  • Consider using the 15.4.7 version of Next.js as a temporary workaround until a more permanent fix is available.
  • Review the Next.js documentation and release notes to see if there are any known issues or fixes related to the prerender manifest matcher and rewritten paths.

Example

  • No code snippet is provided as the issue is related to a specific version of Next.js and its handling of prerender manifest matching.

Notes

  • The issue seems to be specific to certain versions of Next.js, with 15.4.2-canary.2 being the first canary version to exhibit the problem.
  • The fix may involve updating to a version of Next.js where the prerender manifest matcher correctly handles rewritten paths.

Recommendation

  • Apply workaround: Upgrade to version 15.4.7 of Next.js, as it is known to work correctly with the prerender manifest matcher and rewritten paths.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING