Next.js and Headless Drupal: Cache Invalidation with Drupal Cache Tags
When building the new website for Wageningen University & Research, I ran into a familiar challenge: How do you combine the performance of Next.js with the flexibility of a headless Drupal CMS, without making editors wait forever for cache invalidation?
The answer lies in a combination of Drupal Cache Tags and Next.js On-Demand Revalidation. In this article, I’ll explain how we implemented this architecture and which pitfalls you should avoid.
This approach applies to any headless Drupal setup using GraphQL, REST or JSON:API and is designed for high-performance Next.js applications that require precise, fast cache invalidation.
The problem: Caching conflicts between Next.js and headless Drupal
When you build a Next.js site using Drupal as a headless CMS, you want two things at the same time:
- Fast pages for visitors (thanks to Next.js static site generation and caching)
- Instant updates for editors when they change content in Drupal
But these two goals conflict. Next.js wants to cache everything for maximum performance, while editors expect their changes to be visible immediately.
Most teams end up with one of these solutions:
- Revalidate every X seconds → inefficient and still delayed updates
- Server-side render everything → slow pages
- Manually clear cache → horrible workflow for editors
ISR helps, but often teams set the revalidate time so low that the benefits almost disappear.
None of these approaches were acceptable for a platform serving tens of thousands of students and researchers.
The solution: Drupal Cache Tags and Next.js On-Demand Revalidation
Drupal provides a powerful cache-tag system. Every content item automatically gets cache tags such as:
node:123
taxonomy_term:45
user:1When content changes, Drupal automatically invalidates everything associated with those tags.
This means in theory we can cache Next.js pages for weeks. In practice, we use 1 hour, so even if revalidation fails we won't be stuck with stale data for long. Simple enough, right?
Architecture overview for a Next.js + Drupal headless setup
Here’s what the flow looks like:
- An editor updates content in Drupal
- Drupal sends a webhook to Next.js containing the relevant cache tags
- Next.js revalidates only the pages that use those tags
- The update is live within seconds
This gives us the best of both worlds: Fast cached pages for 99.9% of requests Immediate updates when content changes
The Challenge: Drupal Cache Tags vs Next.js Cache Tags
At first glance, it seems easy, just use Drupal cache tags as Next.js cache tags.
But one Drupal cache tag (e.g., node:123) might be used across dozens of different GraphQL queries on different pages.
And the front-end doesn’t know which Drupal entities it will receive until after the fetch completes.
So how do you ensure your fetch gets the right tags so that it can later be invalidated?
Our Solution: A Mapping Layer Between Drupal Cache Tags and Next.js Cache Tags
Implementation: GraphQL cache tag mapping between Drupal and Next.js
The core of our approach: Every GraphQL query gets a unique ID, and we store which Drupal cache tags belong to that query.
In our case, Drupal sends cache tags in the response headers.
// Generate unique ID based on query + variables
const queryId = generateQueryId(query, variables);
const queryString = queryToString(query);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: queryString, variables }),
cache: 'force-cache',
next: { tags: [queryId] },
});
const data = await response.json();
if (response.headers) {
const cacheTagsHeader = response.headers.get('X-Cache-Tags');
const cacheTags = parseCacheTags(cacheTagsHeader);
if (cacheTags.length > 0) {
storeQueryCacheTags(queryId, cacheTags).catch((error) => {
console.error('Failed to store query cache tags:', error);
});
}
}
return data;generateQueryId creates a hash of the query and variables, meaning the same query with the same variables always receives the same ID.
Different queries (or the same query with different variables) get unique IDs.
Saving Cache Tags in Redis
We store the mapping between query IDs and Drupal cache tags in Redis:
export async function storeQueryCacheTags(queryId: string, cacheTags: string[]) {
// Save mapping: queryId -> [cache tags]
await redis.set(
`query:${queryId}:tags`,
JSON.stringify(cacheTags),
'EX',
60 * 60 * 24 * 7 // 7 days TTL
);
// Reverse mapping: cache tag -> [queryIds]
for (const tag of cacheTags) {
await redis.sadd(`tag:${tag}:queries`, queryId);
await redis.expire(`tag:${tag}:queries`, 60 * 60 * 24 * 7);
}
}This gives us:
query:ABC123:tags → ["node:123", "taxonomy_term:45"]
tag:node:123:queries → ["ABC123", "DEF456", "GHI789"]
The reversed mapping lets us efficiently answer: “Which queries use this Drupal cache tag?”
Webhook Endpoint for Revalidation
When Drupal content changes, it sends a webhook with cache tags. Our Next.js endpoint uses them to revalidate the right queries:
async function handler(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const path = searchParams.get('path');
const tags = searchParams.get('tags'); // Can be comma-separated list of Drupal cache tags
const secret = searchParams.get('secret');
// Validate secret.
if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) {
return new Response('Invalid secret.', { status: 401 });
}
const revalidated = {
tags: null as RevalidateResult,
path: null as RevalidateResult,
queryIds: [] as string[],
};
if (!path && !tags) {
return Response.json(
{
revalidated: false,
now: Date.now(),
message: 'Missing path or tags to revalidate',
},
{ status: 400 }
);
}
if (tags) {
// Support both single tag and comma-separated tags
const cacheTags = tags.split(',');
// Query database to find all queryIds associated with these cache tags
const queryIds = await getQueryIdsByCacheTags(cacheTags);
// Revalidate each queryId (which is used as a Next.js cache tag)
for (const queryId of queryIds) {
revalidateTag(queryId);
}
await deleteCacheTagsByQueryIds(queryIds)
revalidated.tags = { revalidated: true, value: tags };
revalidated.queryIds = queryIds;
}
if (path) {
revalidatePath(path);
revalidated.path = { revalidated: true, value: path };
}
return Response.json({
revalidated: true,
details: revalidated,
now: Date.now(),
});
}We can also revalidate a specific path if needed.
Drupal Configuration: Webhooks
On the Drupal side, configure a webhook triggered by:
- node create/update/delete
The webhook must include the cache tags of the updated entity. Drupal’s cache tag system automatically collects:
- the node
- referenced taxonomy terms
- media items
- custom entities
So no manual work is needed.
The payload looks like:
tags=node:123,taxonomy_term:45Why This Approach Works
1. Precision Only the queries affected by the content change are revalidated, not every query that ever used nodes.
2. Scalability Even with thousands of pages, the system stays fast because the revalidation scope is tiny.
3. Debugging Power You can inspect Redis to see exactly which Drupal entities each query uses.
4. Flexibility You can later batch revalidations, prioritize certain queries, or add more layers.
Common Pitfalls
1. Overly Broad Drupal Cache Tags
If Drupal includes a tag like node_list, then every list query will be revalidated, even if the changed node isn’t part of that list.
Better: Use specific tags like:
node:1232. Redis Growth
Each unique query creates a mapping. To manage growth:
- Set TTLs, e.g., 7 days
- Periodic cleanup job
- Monitor usage
At Wageningen University, with thousands of pages, the Redis DB stays under 100MB.
Horizontal Scaling & Cache Synchronization
When scaling Next.js across multiple servers, each instance has its own in-memory cache.
Meaning one revalidation request only affects a single server.
The solution: Use a custom cache handler that replaces the in-memory cache with Redis.
All instances then share the same cache and revalidation works consistently across the fleet.
Results
After implementing this system:
- Editors see updates in 2–5 seconds
- 99%+ of requests are served from cache
- No unnecessary rebuilds
- Works for thousands of pages
- Drupal load is dramatically reduced
- Redis stays under 100MB
- Revalidations complete in 150–300ms
FAQ Q: Does this work with JSON:API or REST instead of GraphQL? A: Yes. As long as Drupal provides cache tags, the mapping layer works the same way.
Q: How is this different from using standard Next.js webhooks? A: This approach focuses on precision: only queries impacted by content changes are revalidated.
Q: Does this support multi-language Drupal setups? A: Yes. Cache tags for translated content work identically.
Conclusion
Combining Next.js and Drupal doesn’t need to be a trade-off between performance and editor experience.
By introducing a mapping layer between Drupal cache tags and Next.js query IDs, you get:
- blazing-fast pages
- instant editor updates
- predictable revalidation
- horizontal scalability
- deep insights into cache usage
Yes, it requires setup (Redis + webhooks), but the payoff is massive. For enterprise projects where performance and content workflows matter, this approach is a no-brainer.
Scales equally well for 100 pages or 10,000 pages, because you only ever revalidate what truly needs revalidating.
This architecture is particularly effective for enterprise headless CMS implementations, large-scale Next.js and Drupal platforms, multi-language websites, and applications powered by complex GraphQL queries.
Questions or comments? I’d be happy to help you implement a similar architecture for your project. Feel free to reach out.
Tags: Next.js, Drupal, Headless CMS, Cache Optimization, On-Demand Revalidation, GraphQL, Enterprise Web Development, Redis