JG—26← All workContact ↗

Technical case study · Morae

CMS events · Next.js caching

Keeping Craft CMS and Next.js in sync.

Extending and debugging an event-driven cache revalidation system across Craft CMS, GraphQL and Next.js.

View Morae project →
Type
Technical case
System
Craft CMS / Next.js
Focus
Caching / events / debugging
Context
Production headless platform

01 / The model

Content freshness starts in the CMS.

This production platform combines SSR, on-demand static generation and the persistent Next.js Data Cache. CMS content has no time-based ISR, so meaningful Craft events drive explicit invalidation.

  1. 01Editor changes content
  2. 02Craft lifecycle event
  3. 03Next.js endpoint
  4. 04Invalidate tags / paths
  5. 05Next affected request
  6. 06Fresh GraphQL data

Invalidation does not immediately rebuild every page. The next affected request fetches fresh data and produces an updated response according to that route’s rendering strategy.

02 / Dependencies

The hard part is deciding what became stale.

A relational CMS mutation can affect more than one route. Craft emits semantic information so Next.js can invalidate fetches and pages associated with meaningful tags and paths.

Entry

section tag · entry tag · path

Global Set

global_<handle>

Category

category-group handle

SEOMatic

seomatic

Special case / reverse lookup
Asset changedWho references it?Entries · root Entries from nested content · Global SetsSemantic invalidation
Assets require targeted reverse-relationship handling. The system does not calculate a complete dependency graph for every mutation type.

03 / The bug

Enabled → disabled was still a content change.

When an Entry was disabled from Craft’s element index, it could remain represented by stale cached data even though it was no longer enabled in the CMS.

Initial hypothesis

Perhaps element-index actions followed a different lifecycle from an individual save.

Elements::EVENT_AFTER_PERFORM_ACTION

I added temporary instrumentation, queried the action criteria and logged affected sections. It did not execute invalidations and was removed after testing the idea.

Useful investigation. Wrong root cause.

04 / Root cause + fix

The save event fired. Application logic discarded it.

The existing save handler checked the Entry’s new enabled state. Once the Entry became disabled, that guard returned before triggering the invalidation needed to remove its previous cached representation.

Old logic / flow stops
Entry::EVENT_AFTER_SAVE↓check entry->enabled↓disabled → return
Corrected lifecycle
Entry::EVENT_AFTER_SAVE↓Invalidate relevant dependencies
AFTER_DELETEAFTER_RESTORE

The smallest correct fix was to remove the enabled-state guard, retaining the Entry lifecycle approach. Delete and restore remain explicit lifecycle paths.

05 / Beyond Entries

Semantic invalidation follows content relationships.

Nested content

Asset → nested block → root Entry → cached page

Shared content

Asset → Global Set → multiple pages

I extended asset-related invalidation behaviour so changes could reach referencing Entries, root Entries and Global Sets. This is targeted relationship handling alongside the broader semantic tag strategy—not a universal dependency resolver.

06 / Takeaway

Cache invalidation must react to content that has just stopped existing from the frontend’s perspective.
01

State transitions are data changes

Disabled, deleted and restored content all need cache consideration.

02

Events encode assumptions

The lifecycle was correct; the enabled-state filter encoded the wrong rule.

03

Invalidation follows dependencies

The useful unit is often a semantic content dependency, not one current page.

My contribution was to extend the existing revalidation system, extend asset-related invalidation behaviour, investigate and reject the Element Action hypothesis, trace the disabled-entry issue to the enabled-state guard and implement the smallest correct fix.

Related project
View Morae project →