Caching and Rendering
Madori separates public rendering, content reads, and operational caches so each layer has an explicit freshness boundary. Projects scaffolded with bundled brochure content enable the Public Cache: rendered public pages served to anonymous visitors without rendering again, built on Next.js incremental static regeneration (ISR). Request-specific pages are always rendered fresh. Blank projects and custom frontends set publicCache.enabled: false until they provide their own public renderer and invalidation policy. Control Panel, authenticated APIs, previews, and form submissions stay private and request-dependent.
Terms used on this page:
- Invalidation: a recorded fact that previously cached public pages must no longer be served. Any change except to private data (users, roles, form submissions) causes one.
- Clear: an Invalidation started deliberately by an operator.
- Pending Invalidation: recorded but not yet applied; public pages are rendered fresh until it is.
- Failed Invalidation: a Pending Invalidation that could not be applied after every retry and now needs a Clear.
- Cache Bypass: a public request deliberately rendered fresh instead of served from the Public Cache, always for a stated reason.
- Cache Warming: requesting every public page ahead of visitors so the Public Cache already holds it.
Public page rendering
Published public routes are safe candidates for the Public Cache. Anonymous
GET and HEAD requests without cookies, authorization, or non-RSC query
parameters can use it; every other public request is a Cache Bypass. RSC requests carrying only Next's _rsc query parameter are
also eligible when their RSC headers are present. Bundled routes resolve through
the internal /madori-internal/[site]/[[...slug]] route so site identity is
explicit; the browser-facing URL remains unchanged. A route must resolve its
configured site and content identity from trusted route data. Do not use
arbitrary request headers, cookies, draft state, search parameters, or user
identity as part of a public cache key.
Keep these paths request-dependent:
- Control Panel pages and APIs
- authenticated GraphQL operations
- draft and preview output
- search and other query-dependent pages
- form submissions and responses that contain private data
The Public Cache runs in a Node.js Next.js server and needs durable storage for
production cache state. The browser-facing HTML and RSC responses carry
Cache-Control: no-store even when Next serves a cached page; this keeps downstream
response caches out of the contract. A static export cannot regenerate content
after publication. Start one writable Madori process per content store;
multiple writers and independent local cache directories are outside bundled
storage contract.
Data and HTML cache layers
Madori has several independent cache owners:
| Layer | Purpose | Invalidation |
|---|---|---|
| Application content and SEO caches | Avoid repeated file parsing and SEO resolution inside Madori | Successful mutations and file-watch events clear affected runtime state |
@madori/sdk tagged reads |
Cache server-side SDK entry reads in a Next.js integration | Call revalidateMadoriCache from integration that owns client |
| Public Cache (Next.js route cache) | Persist rendered public pages | Invalidation after every non-private change; Clear for operators |
| HTML/RSC/API/CDN cache | Cache responses outside application | Purge through provider, or leave response caching disabled |
Clearing one layer does not clear another. A content write should commit durable
data first, then clear application indexes, expire SDK tags, invalidate the
Public Cache, and purge any separately managed CDN copy. Every Invalidation
covers the whole site, so old and new slugs, collection lists, and referencing
pages all render again safely (see ADR 0002 in src/docs/adr/). Pages render
again lazily on their next request unless deployment adds Cache Warming.
SDK caching
Server-side SDK clients derive stable cache namespace from resolved content and resources paths. Supply cacheNamespace when two clients intentionally share or need to separate identity:
import { createClient } from '@madori/sdk'
import { revalidateMadoriCache } from '@madori/sdk/hooks/server'
const client = createClient({
contentPath: './content',
resourcesPath: './resources',
cacheNamespace: 'production-site',
})
// In a Route Handler or webhook consumer after a successful publish:
revalidateMadoriCache(client, 'blog')
taggedGetEntry and taggedListEntries use source and collection tags and default to a 60-second revalidation window. Pass { revalidate: 300 } to either tagged-reader factory when a longer recovery interval is intentional. revalidateMadoriCache always uses hard expiry for immediate freshness after an acknowledged mutation. A client with ambiguous source identity must provide cacheNamespace.
SDK does not receive invalidation events from a separate Madori deployment automatically. Authenticate webhook or queue delivery in integration, verify mutation, and retry failed invalidation. Never put privileged credentials in browser bundle.
Public APIs and CDN boundaries
Published entry and public form-schema responses use no-store semantics by default so origin invalidation cannot be hidden by independent downstream cache. If you add CDN or reverse-proxy cache, treat it as separate owner: preserve RSC and query variants, exclude authenticated or preview requests, and provide provider-specific purge delivery for edits, unpublishes, deletes, and slug changes.
Hashed framework assets can use long-lived immutable caching. Uploaded assets must continue through Madori asset route so metadata sidecars and download restrictions are not exposed by direct directory mapping.
Public Cache configuration
The Public Cache is enabled by default for bundled public pages. Configure its durable state and revalidation bridge when deploying:
const config = {
publicCache: {
enabled: true,
storagePath: './storage/public-cache',
exclude: ['/preview/**', '/search'],
revalidationPath: '/api/internal/revalidate',
requestTimeoutMs: 5000,
retryLimit: 12,
},
}
Madori reads an explicit publicCache.secret or MADORI_REVALIDATION_SECRET,
then generates a private token in durable local state when neither is set. The
bridge defaults to http://127.0.0.1:${PORT || 3000} plus
publicCache.revalidationPath; set MADORI_REVALIDATION_URL when next start
uses a different port or origin. Keep the bridge authenticated and private. On
startup, Madori records an Invalidation to catch edits made while the server was
offline, and retries any Pending Invalidation. A missing state file on first
startup means nothing is pending; malformed or unreadable state makes every
public request a Cache Bypass until the state is repaired. Applying an
Invalidation expires cached pages and refreshes application content and SEO
readers before it is confirmed.
The bridge retries with the configured timeout and limit. When every retry has
failed, it becomes a Failed Invalidation: public requests stay a Cache Bypass
until a new content change, Clear Public Cache in Control Panel settings, or
cache:clear records a fresh Invalidation. Restarting alone does not recover a
Failed Invalidation.
If the Invalidation itself cannot be recorded (for example, full storage), the
content write still succeeds. Madori expires cached pages directly when it can,
makes public requests a Cache Bypass, and reports state-write-failed until a
later write succeeds.
Form submissions are private data and never cause an Invalidation, whether they
arrive through the public form endpoint or appear on disk under content/forms/.
Monitoring cache state
- Readiness:
GET /api/health/readyincludes apublicCacheStatecheck. A Failed Invalidation (failed), unreadable state (invalid) or a failed state write (state-write-failed) reportsdegradedwith HTTP 200: pages are still correct through Cache Bypass, so the instance should keep serving while an operator investigates. Storage failures remainunavailable(HTTP 503). - Control Panel: Settings → Public Cache shows state, last change reason,
acknowledgement time, Invalidation latency, bridge attempts and last redacted
error. Users with settings edit permission can Clear there
(
POST /api/settings/cache/clear);GET /api/settings/cachereturns the same status as JSON. - Response header: public page responses include
x-madori-cache: public-cacheorx-madori-cache: bypass; reason=<reason>, where reason is one ofdisabled,pending,method,query,cookie,authorization,bypass-header,system,asset,nocacheorexcluded. Combine with Next.jsx-nextjs-cache(HIT,MISS,STALE) for Public Cache outcomes. - Logs: structured events
public_cache.bridge.unavailable,public_cache.invalidation.failed,public_cache.invalidation.recovered,public_cache.state.invalid,public_cache.state.write_failedandpublic_cache.stale_risk.
Regeneration failures
After an Invalidation is applied (a content change, file edit or Clear), cached
pages are hard-expired. If rendering then throws (for example, an unreadable
content file), the request returns HTTP 500 with no-store; the previously
cached page is never served. Every request retries rendering, and the first
successful render after the cause is fixed is cached again (MISS, then HIT).
No Clear is needed. Madori logs each failed
render as request.failed with the route.
The 60-second recovery interval is different: it refreshes in the background when no content changed. If that background refresh fails, Next.js keeps the last good output (still current content) and retries on a later request.
Run maintenance checks from the application checkout:
pnpm cache:clear
pnpm cache:warm --base-url https://example.com
cache:clear performs a Clear and fails unless the bridge confirms it, leaving
a Pending Invalidation behind on failure. cache:warm performs Cache Warming: it discovers canonical collection, taxonomy,
navigation, and home routes, then verifies a second request and expects a cache
hit by default. Pass --no-verify-hits when checking route availability without
requiring the second-hit header. Route discovery includes published collection
entries, non-draft taxonomy terms, navigation links, and both .yaml and
.yml definitions; invalid configuration fails the command. Supplying
--base-url overrides configured site URLs and warms one target, including any
path prefix in that URL. Neither command purges an external CDN.
Legacy static cache (deprecated)
staticCache is deprecated and will be removed in the next minor release (ADR 0001 in src/docs/adr/). It never cached bundled public pages, which use the Public Cache. Madori logs a warning at startup while it is enabled. Integrations that cache Madori content in their own frontend should use the SDK's tagged reads instead.
const config = {
staticCache: {
enabled: false,
driver: 'application',
storagePath: './storage/static-cache',
queryStrings: 'ignore',
warmOnInvalidate: false,
},
}
If a custom integration still uses this driver, use queryStrings: 'separate' only when query values change rendered page and bound resulting key space. Keep default ignore for pages whose HTML is query-invariant. Cache files are regenerable deployment state; clear them after code release if generated HTML could reference obsolete assets.
Invalidation checklist
Every Invalidation covers the whole site. When changing or extending invalidation behavior, verify entry's old and new URLs, collection listings, referencing pages, taxonomy pages, navigation and globals, metadata, sitemap, and feeds. Include previously missing URL when it becomes published. Test edit, publish, unpublish, delete, slug changes, out-of-band file edits, process restart, and failed regeneration.
Use health endpoints to distinguish process, storage and cache-state failures:
GET /api/health/live
GET /api/health/ready
See Configuration, SDK and content access, and Deployment for deployment checks. The dated caching research record in the repository contains evidence and acceptance criteria.