A headless Drupal site using Next.js 16 on Pantheon with tag-based cache invalidation via the Pantheon cache handler.
Based on the next-drupal basic starter (next-drupal ^2.0.0-beta.2).
- Architecture Overview
- Drupal Setup
- Next.js Setup
- Project Structure
- Environment Variables
- Pantheon Cache Handler
- Cache Invalidation Flow
- Tag Convention
- Upgrading from Next.js 15 to 16
- Verifying Cache Behavior
- Gotchas
Drupal (CMS) ──JSON:API──> Next.js 16 (App Router) ──> Pantheon CDN
│
GCS Cache Handler
(shared cache + edge purge)
- Drupal serves content via JSON:API and sends webhook notifications on content changes
- Next.js renders pages using ISR (Incremental Static Regeneration) with 60-second revalidation
- Pantheon cache handler stores cache in GCS (shared across server instances) and purges CDN edge cache on invalidation
- Tag-based invalidation allows Drupal content saves to immediately purge specific cached pages via surrogate keys
Install and configure the Next.js module for Drupal:
- Enable JSON:API (core) and the
nextcontrib module - Configure a Next.js site at
/admin/config/services/next - Set the revalidation URL to
https://YOUR-NEXTJS-SITE/api/revalidate - Set a revalidation secret (must match
DRUPAL_REVALIDATE_SECRETenv var on the Next.js side)
It is highly suggested that the Pantheon Advanced Page Cache be installed as well.
When content is saved, the next module sends a webhook:
GET /api/revalidate?secret=XXX&tags=node:16,node_list:article
node:NID-- entity-specific tag (e.g.,node:16for node ID 16)node_list:BUNDLE-- listing tag (e.g.,node_list:articlefor article listings)
For accessing unpublished content or restricted fields, configure a consumer at /admin/config/services/consumer and set DRUPAL_CLIENT_ID and DRUPAL_CLIENT_SECRET on the Next.js side. Then uncomment the auth block in lib/drupal.ts.
This assumes you have set up a NextJS site on Pantheon, using either Terminus or via the site dashboard. Instructions can be found at the Pantheon Documentation section for NextJS.
Install the Next+Drupal starter package. Note - by default it will install with Next 15. This starter package/repo has been upgraded to Next 16 already.
npx create-next-app -e https://github.com/chapter-three/next-drupal-basic-starterThen follow the Upgrading from Next.js 15 to 16 section and the Pantheon Cache Handler section.
| Package | Version | Purpose |
|---|---|---|
next |
^16.1.6 |
Framework |
next-drupal |
^2.0.0-beta.2 |
Drupal JSON:API client |
react / react-dom |
^19.2.4 |
React 19 |
@pantheon-systems/nextjs-cache-handler |
^0.4.0 |
GCS cache + CDN edge purge |
├── app/
│ ├── layout.tsx # Root layout with nav and draft alert
│ ├── page.tsx # Homepage (article listing)
│ ├── [...slug]/
│ │ └── page.tsx # Dynamic routes (articles, pages)
│ └── api/
│ ├── revalidate/route.ts # Webhook endpoint for Drupal
│ ├── draft/route.ts # Draft mode enable
│ └── disable-draft/route.ts # Draft mode disable
├── components/
│ ├── drupal/
│ │ ├── Article.tsx # Full article view
│ │ ├── ArticleTeaser.tsx # Article card for listings
│ │ └── BasicPage.tsx # Basic page view
│ ├── misc/
│ │ └── DraftAlert/ # Draft mode banner
│ └── navigation/
│ ├── HeaderNav.tsx # Site header
│ └── Link.tsx # Navigation link
├── lib/
│ ├── drupal.ts # NextDrupal client instance
│ └── utils.ts # Date formatting, absolute URLs
├── cache-handler.mjs # Pantheon cache handler entry point
├── next.config.mjs # Next.js config with cache handler
└── .env.local # Environment variables
lib/drupal.ts -- NextDrupal client instance:
import { NextDrupal } from "next-drupal"
export const drupal = new NextDrupal(process.env.NEXT_PUBLIC_DRUPAL_BASE_URL as string, {
// auth: { clientId, clientSecret },
// withAuth: true,
})app/page.tsx -- Homepage with tagged fetch:
export const revalidate = 60
export default async function Home() {
const nodes = await drupal.getResourceCollection<DrupalNode[]>(
"node--article",
{
params: {
"filter[status]": 1,
"fields[node--article]": "title,path,field_image,uid,created,body",
include: "field_image,uid",
sort: "-created",
},
next: { revalidate: 60, tags: ["node_list:article"] },
}
)
// render nodes...
}app/[...slug]/page.tsx -- Dynamic routes with entity-specific tags:
export const revalidate = 60
async function getNode(slug: string[]) {
const path = `/${slug.join("/")}`
const translatedPath = await drupal.translatePath(path)
const type = translatedPath.jsonapi?.resourceName!
const uuid = translatedPath.entity.uuid
const entityId = translatedPath.entity.id
const resource = await drupal.getResource<DrupalNode>(type, uuid, {
params,
next: { revalidate: 60, tags: [`node:${entityId}`, type] },
})
return resource
}app/api/revalidate/route.ts -- Webhook handler:
import { revalidatePath, revalidateTag } from "next/cache"
async function handler(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const path = searchParams.get("path")
const tags = searchParams.get("tags")
const secret = searchParams.get("secret")
if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) {
return new Response("Invalid secret.", { status: 401 })
}
if (!path && !tags) {
return new Response("Missing path or tags.", { status: 400 })
}
try {
path && revalidatePath(path)
tags?.split(",").forEach((tag) => revalidateTag(tag, "default"))
return new Response("Revalidated.")
} catch (error) {
return new Response((error as Error).message, { status: 500 })
}
}
export { handler as GET, handler as POST }The following environment variables need to be set on the NextJS site, both in .env.local as well as via Pantheon's Terminus Secrets. This can be done via the Dashboard, or through the use of the Terminus Secrets Manager Plugin.
# Required
NEXT_PUBLIC_DRUPAL_BASE_URL=https://live-your-site.pantheonsite.io
NEXT_IMAGE_DOMAIN=live-your-site.pantheonsite.io
# Authentication (optional -- for accessing unpublished content)
DRUPAL_CLIENT_ID=from /admin/config/services/consumer
DRUPAL_CLIENT_SECRET=from /admin/config/services/consumer
# Required for on-demand revalidation
DRUPAL_REVALIDATE_SECRET=from /admin/config/services/next
CACHE_BUCKET and OUTBOUND_PROXY_ENDPOINT are set automatically on Pantheon infrastructure. CACHE_DEBUG=true enables verbose cache handler logging.
Without the cache handler, each Next.js server instance has its own local file cache, and the Pantheon CDN edge cache is not actively cleared on content updates. Pages only refresh when the ISR timer (60s) expires.
With the cache handler:
- Cache is stored in GCS (shared across all server instances)
- CDN edge cache is purged immediately on invalidation via surrogate keys
- Tag-based invalidation from Drupal webhooks deletes specific cache entries and triggers fresh renders
1. Install the package:
npm install @pantheon-systems/nextjs-cache-handler2. Create cache-handler.mjs in the project root:
import { createCacheHandler } from "@pantheon-systems/nextjs-cache-handler";
const CacheHandler = createCacheHandler({ type: "auto" });
export default CacheHandler;auto selects GCS when CACHE_BUCKET exists (Pantheon production/multidev), file-based otherwise (local dev).
3. Update next.config.mjs:
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
cacheHandler: path.resolve(__dirname, "./cache-handler.mjs"),
cacheMaxMemorySize: 0,
images: {
remotePatterns: process.env.NEXT_IMAGE_DOMAIN ? [
{
protocol: "https",
hostname: process.env.NEXT_IMAGE_DOMAIN,
pathname: "/sites/default/files/**",
},
] : [],
},
};
export default nextConfig;cacheHandlerpoints to the handler filecacheMaxMemorySize: 0disables the in-memory LRU cache so all cache operations go through the handler
4. Add next: { revalidate, tags } to all fetch calls.
This is the critical step. See Gotchas for why both revalidate and tags are required.
Use cacheHandler (singular). Do not enable cacheHandlers (plural), cacheComponents, or the use cache directive. The export const revalidate route segment config is incompatible with cacheComponents: true.
- Content is saved in Drupal
- Drupal's
nextmodule sends a webhook:/api/revalidate?secret=XXX&tags=node:16,node_list:article - The revalidate handler calls
revalidateTag("node:16", "default")andrevalidateTag("node_list:article", "default") - The GCS cache handler looks up each tag in its tag mapping, finds the associated fetch cache entries, and deletes them
- The edge cache handler purges the surrogate keys from the CDN
- The next request hits origin -- the route cache entry is served (stale-while-revalidate) while a background regeneration fetches fresh data from Drupal
- The new page is stored in GCS route cache, new fetch cache entries are created with tag mappings restored, and the CDN edge cache for those paths is cleared
- Subsequent requests get the fresh page
Tags in Next.js fetch calls must match what Drupal sends in its webhook.
| Page | Next.js tags | Drupal webhook sends |
|---|---|---|
| Homepage (article listing) | node_list:article |
tags=node_list:article |
| Individual article (node 16) | node:16, node--article |
tags=node:16 |
| Individual basic page (node 5) | node:5, node--page |
tags=node:5 |
node:NIDenables per-entity invalidation (only the changed node's page is purged)node--article/node--pageenables bundle-wide invalidation (all articles or all pages)node_list:articleinvalidates collection pages (homepage listing)
The next-drupal library passes the next option through to the native fetch() call via JsonApiWithNextFetchOptions, so tags set on getResource() and getResourceCollection() are forwarded correctly.
If starting from the basic starter (which ships with Next.js 15), these changes are required:
Add ESM module type:
"type": "module"Bump dependencies:
| Package | Next 15 | Next 16 |
|---|---|---|
next |
^15.1.2 |
^16.1.6 |
react |
^19.0.0 |
^19.2.4 |
react-dom |
^19.0.0 |
^19.2.4 |
@types/react |
^19.0.0 |
^19.2.14 |
@types/react-dom |
^19.0.0 |
^19.2.3 |
eslint |
^8.57.0 |
^9.39.2 |
eslint-config-next |
^15.0.4 |
^16.1.6 |
Add an override so next-drupal uses the installed Next.js version instead of its own peer dependency:
"overrides": {
"next-drupal": {
"next": "$next"
}
}Rename from .js to .mjs and switch from CommonJS to ESM:
- module.exports = nextConfig
+ export default nextConfigRename to .cjs because "type": "module" in package.json makes .js files ESM by default. PostCSS config uses module.exports, so it needs the explicit .cjs extension.
revalidateTag requires a second argument in Next.js 16 -- the cache life profile name:
- revalidateTag(tag)
+ revalidateTag(tag, "default")export const revalidate = 60on route segments works identicallyrevalidatePath()signature is unchangedgenerateStaticParams()works identicallynext-drupalclient methods (translatePath,getResource,getResourceCollection,getResourceCollectionPathSegments) all work without changes- Tailwind, PostCSS, and TypeScript configs are functionally unchanged
Example log entries are below.
npm run build should show tag mapping operations:
[FileCacheHandler] Updated tags mapping for 46cd28a... with tags: [ 'node_list:article' ]
[FileCacheHandler] Updated tags mapping for f37e424... with tags: [ 'node:16', 'node--article' ]
After saving content in Drupal, runtime logs should show:
[GcsCacheHandler] REVALIDATE TAG: node_list:article
[GcsCacheHandler] Found 1 cache entries for tag: node_list:article
[GcsCacheHandler] Deleted fetch cache entry: 46cd28a4...
[GcsCacheHandler] Revalidated 1 entries for tags: node_list:article
[EdgeCacheClear] Background key clear for tag revalidation: node_list:article: 1 keys cleared
If you see No cache entries found for tag, the fetch-level caching is not configured correctly. See Gotchas.
curl -I -H "Pantheon-Debug:1" https://YOUR-SITE.pantheonsite.io/Confirm:
surrogate-key-rawincludes your tags (e.g.,node_list:article)x-next-cache-tagsincludes your tagsage: 0after invalidation (fresh from origin)x-cache: MISSafter invalidation (not served from CDN cache)
curl "https://YOUR-SITE.pantheonsite.io/api/revalidate?secret=YOUR_SECRET&tags=node_list:article"Should return Revalidated.
The most common pitfall. In Next.js 16, fetch() defaults to no-store. You must set both revalidate and tags in the next option on every fetch call:
next: { revalidate: 60, tags: ["node_list:article"] }Without revalidate, the fetch response is never stored in the cache handler, no tag-to-entry mappings are created in GCS, and revalidateTag finds 0 entries to invalidate. The runtime logs will show:
[GcsCacheHandler] No cache entries found for tag: node_list:article
[GcsCacheHandler] Revalidated 0 entries for tags: node_list:article
The export const revalidate = 60 route segment config controls the ISR timer for the rendered page. It does not enable fetch-level caching.
Tags in Next.js fetch calls must exactly match what Drupal sends in its webhook. The Drupal next module sends node:NID (e.g., node:16), not node--article. Use translatedPath.entity.id to get the numeric entity ID for tagging.
After tag invalidation, the first request serves the stale page while regenerating in the background. The fresh page is available on the second request. This is standard ISR behavior. The cache-control header includes stale-while-revalidate=31535940 (1 year), allowing the CDN to serve stale content during regeneration.
Tag mappings in GCS only exist after a fetch cache entry has been SET at runtime. After a fresh deploy, pages need to be visited at least once to create the fetch cache entries and their tag mappings. Until then, revalidateTag will find 0 entries for those tags.
- next-drupal.org -- next-drupal library docs
- @pantheon-systems/nextjs-cache-handler -- Pantheon cache handler package
- Next.js Caching -- Next.js caching documentation