MADORIMADORI

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/ready includes a publicCacheState check. A Failed Invalidation (failed), unreadable state (invalid) or a failed state write (state-write-failed) reports degraded with HTTP 200: pages are still correct through Cache Bypass, so the instance should keep serving while an operator investigates. Storage failures remain unavailable (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/cache returns the same status as JSON.
  • Response header: public page responses include x-madori-cache: public-cache or x-madori-cache: bypass; reason=<reason>, where reason is one of disabled, pending, method, query, cookie, authorization, bypass-header, system, asset, nocache or excluded. Combine with Next.js x-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_failed and public_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.