We recently deployed our Sitecore Next.js site on Netlify and ran into a frustrating caching problem. Even after revalidating pages, they stayed cached. The standard Next.js ISR revalidation just wasn't working reliably on Netlify's edge network, which meant content authors would publish updates and they wouldn't show up for users.
I'll walk you through how we solved this by combining Next.js ISR revalidation with Netlify's cache tag purge API. We also leverage a Sitecore PowerShell script that triggers the endpoint for selected pages or items directly from the Content Editor. It's a pretty straightforward approach once you understand how Netlify's caching works.
The Problem
When we deployed our Next.js app with ISR to Netlify, we quickly noticed something wasn't right. Pages were staying cached at the edge even after we triggered revalidation. Here's what was happening:
- Stale Content: Authors would publish updates in Sitecore and trigger revalidation, but users kept seeing the old version for way too long.
- ISR Limitations: The
res.revalidate(path) method that works great on Vercel doesn't always make it through Netlify's edge network. It's hit or miss.
- No Granular Control: Without cache tags, we couldn't target specific pages. Our options were basically "purge everything" or "wait for the cache to expire" - neither is ideal.
- Author Experience: Content authors started losing trust in the system. They'd constantly refresh to see if their changes were live, which led to support tickets and manual cache clearing.
Netlify's edge caching is great for performance, but it works differently than what we were used to. We needed to work with Netlify's system, not against it.
The Solution
We ended up with a three-part solution that plays nicely with Netlify's caching:
-
Middleware Cache Tagging: We use Next.js middleware to stamp every HTML route with a Netlify-Cache-Tag header. This happens automatically before the page gets cached, so each page has a unique identifier.
-
Revalidation API: Our /api/revalidate endpoint tries the standard Next.js ISR revalidation first (just in case it works), then calls Netlify's purge API with the matching cache tag. This way we cover both bases.
-
Sitecore Integration: A simple PowerShell script in Sitecore calls our revalidation API when content gets published. Authors don't have to change their workflow at all.
Here's the decision flow:
Request for /en/products/abc
└─> Middleware intercepts
| └─> Builds cache tag: page:en_products_abc
| └─> Sets Netlify-Cache-Tag header
| └─> Page cached with tag
|
Content author publishes in Sitecore
└─> PowerShell script executes
| └─> POST to /api/revalidate
| └─> Verify secret
| └─> Try res.revalidate(path) (best-effort)
| └─> Build matching cache tag
| └─> Call Netlify purge API with tag
| └─> Netlify invalidates page across CDN
This gives us the best of both worlds: we keep the performance benefits of edge caching, but we can also invalidate specific pages whenever we need to. The cache tag system is the key - it lets us target exactly what we want to clear.
How It Works
Let me break down the code so you can see how everything fits together.
middleware.ts - Cache Tag Stamping
This is where it all starts. The middleware runs before any page is rendered and stamps each route with a cache tag.
File Name & Role: middleware.ts (root of your Next.js app). This middleware intercepts requests and adds cache tag headers to HTML routes, so Netlify knows which tag belongs to which page.
What it exports:
middleware: The main function that processes requests
buildCacheTag: A helper that converts paths into cache tag format
Code:
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
function buildCacheTag(path: string): string {
if (!path || path === '/') {
return 'page:home';
}
const normalized = path
.trim()
.replace(/^\/+|\/+$/g, '')
.replace(/\//g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '');
return `page:${normalized || 'home'}`;
}
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (
pathname.startsWith('/api') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon') ||
pathname.match(/\.(ico|png|jpg|jpeg|gif|webp|svg|css|js|txt|xml)$/)
) {
return NextResponse.next();
}
const res = NextResponse.next();
const cacheTag = buildCacheTag(pathname);
res.headers.set('Netlify-Cache-Tag', cacheTag);
res.headers.set('Cache-Tag', cacheTag);
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
};
Why this works:
- Automatic Tagging: Every HTML route gets tagged automatically, so you don't have to think about it. It just works.
- Normalization: The
buildCacheTag function turns paths like /en/products/abc into page:en_products_abc. This makes the tags predictable and URL-safe, and they match what we use in the revalidation API.
- Selective Application: We skip API routes, Next.js internals, and static files - they don't need cache tags anyway.
- Dual Headers: We set both
Netlify-Cache-Tag and Cache-Tag headers. Netlify uses the first one, but the second is a standard that other CDNs recognize too, so you're covered if you switch platforms later.
pages/api/revalidate.ts - Revalidation + Purge
This is where the magic happens. The endpoint tries Next.js ISR revalidation first, then calls Netlify's purge API.
File Name & Role: pages/api/revalidate.ts. This API route handles revalidation requests from Sitecore. It tries ISR revalidation (just in case), then purges Netlify's cache using the cache tag.
What it exports:
handler: The main API route handler
RevalidateRequest: TypeScript interface for the request body
buildCacheTag: Same helper function as the middleware - the logic has to match exactly
Code:
import type { NextApiRequest, NextApiResponse } from 'next';
export interface RevalidateRequest {
url?: string;
secret?: string;
siteName?: string;
}
function buildCacheTag(path: string): string {
if (!path || path === '/') {
return 'page:home';
}
const normalized = path
.trim()
.replace(/^\/+|\/+$/g, '')
.replace(/\//g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '');
return `page:${normalized || 'home'}`;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const revalidateRequest = req.body as RevalidateRequest;
let revalidated = false;
if (revalidateRequest.secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).end('Unauthorized');
}
let pathToClear = '/';
if (revalidateRequest?.url) {
pathToClear = revalidateRequest.url;
}
if (!pathToClear) {
return res.status(400).end('Revalidated : false');
}
try {
await res.revalidate(pathToClear);
revalidated = true;
} catch (err) {
// Continue with Netlify purge
}
try {
const cacheTag = buildCacheTag(pathToClear);
const siteId = process.env.NETLIFY_SITE_ID;
const siteSlug = process.env.NETLIFY_SITE_SLUG;
const token = process.env.NETLIFY_PAT;
if (!token || (!siteId && !siteSlug)) {
return res.status(200).end('Revalidated : ' + revalidated);
}
const body: Record<string, unknown> = {
cache_tags: [cacheTag],
};
if (siteId) {
body.site_id = siteId;
}
if (siteSlug) {
body.site_slug = siteSlug;
}
const purgeResp = await fetch('https://api.netlify.com/api/v1/purge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!purgeResp.ok) {
const text = await purgeResp.text();
throw new Error(`Netlify purge failed: ${purgeResp.status} ${text}`);
}
} catch (err) {
// Continue even if purge fails
}
return res.status(200).end('Revalidated : ' + revalidated);
}
Why this works:
- Dual Strategy: We try both ISR revalidation and Netlify purge. If one fails, the other might still work, so we're covered either way.
- Tag Matching: The
buildCacheTag function uses the exact same logic as the middleware. This is crucial - the tag we purge has to match the tag that was set when the page was cached.
- Graceful Degradation: If Netlify config is missing, we still try ISR revalidation and return success. Better than breaking the authoring workflow.
- Security: The secret check keeps unauthorized people from clearing your cache.
Sitecore PowerShell Script
This PowerShell script hooks into Sitecore's context menu to trigger revalidation when content gets published.
File Name & Role: Sitecore PowerShell script (Context Menu). Authors right-click an item in Sitecore and select the revalidation option, which runs this script.
Code:
function ClearCacheForItem {
Param ([Sitecore.Data.Items.Item]$item)
$siteResolver = [Sitecore.DependencyInjection.ServiceLocator]::ServiceProvider.GetService([Sitecore.Sites.IItemSiteResolver])
$defaultSiteInfo = $null
foreach ($language in $item.Languages) {
$langItem = Get-Item -Path "master:" -ID $item.ID -Language $language -ErrorAction SilentlyContinue
if ($langItem -ne $null -and $langItem.Versions.Count -gt 0) {
$testSiteInfo = $siteResolver.ResolveSite($langItem)
if ($testSiteInfo -ne $null -and $testSiteInfo.TargetHostName -ne $null -and $testSiteInfo.TargetHostName -ne "") {
$defaultSiteInfo = $testSiteInfo
break
}
}
}
if ($defaultSiteInfo -eq $null) {
Write-Host "ERROR: Could not find valid site information." -ForegroundColor Red
return
}
foreach ($lang in $item.Languages) {
$languageItem = Get-Item -Path "master:" -ID $item.ID -Language $lang
if ($languageItem.Versions.Count -eq 0 -or $languageItem.Visualization.Layout -eq $null) {
continue
}
$siteInfo = $siteResolver.ResolveSite($languageItem)
if ($siteInfo -eq $null -or $siteInfo.TargetHostName -eq $null -or $siteInfo.TargetHostName -eq "") {
$siteInfo = $defaultSiteInfo
}
$scheme = if ($siteInfo.Scheme) { $siteInfo.Scheme } else { "https" }
$cleanHostName = $siteInfo.TargetHostName
if ($cleanHostName.StartsWith("www.")) {
$cleanHostName = $cleanHostName.Substring(4)
}
$siteBaseUrl = "$scheme://$cleanHostName"
$siteContext = New-Object Sitecore.Sites.SiteContext $siteInfo
$options = New-Object Sitecore.Links.UrlOptions
$options.AlwaysIncludeServerUrl = $false
$options.Language = $lang
$options.SiteResolving = $true
$options.Site = $siteContext
$options.LanguageEmbedding = [Sitecore.Links.LanguageEmbedding]::Always
$itemUrl = [Sitecore.Links.LinkManager]::GetItemUrl($languageItem, $options)
$itemRelativeUrl = $itemUrl
if ($itemUrl.Contains($siteInfo.TargetHostName)) {
$itemRelativeUrl = $itemUrl.Substring($itemUrl.IndexOf($siteInfo.TargetHostName) + $siteInfo.TargetHostName.Length)
}
$revalidatePostUrl = "$siteBaseUrl/api/revalidate"
$body = @{
url = $itemRelativeUrl
secret = "YOUR_REVALIDATE_SECRET"
siteName = $siteInfo.Name
} | ConvertTo-Json
try {
$response = Invoke-RestMethod -Uri $revalidatePostUrl -Method Post -Body $body -ContentType "application/json"
Write-Host "Response: $response" -ForegroundColor Green
} catch {
Write-Host "ERROR: Failed to invalidate URL: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
$item = Get-Item -Path .
ClearCacheForItem -item $item
Why this works:
- The script automatically triggers cache invalidation when content is published.
- It handles multiple sites and languages without any extra work.
- If something goes wrong, it logs the error but doesn't break the authoring experience.
Getting Started
Here's what you need to do to get this set up:
-
Add Middleware: Create middleware.ts in the root of your Next.js app. This automatically stamps cache tags on all HTML routes.
-
Create Revalidation API: Add pages/api/revalidate.ts with the revalidation logic that calls Netlify's purge API.
-
Configure Environment Variables: In Netlify, add these environment variables:
REVALIDATE_SECRET: A secure secret for API authentication
NETLIFY_PAT: Your Netlify Personal Access Token
NETLIFY_SITE_ID or NETLIFY_SITE_SLUG: Your Netlify site identifier
-
Update PowerShell Script: Replace your existing Sitecore PowerShell script with the version above, and update the REVALIDATE_SECRET value.
-
Test It Out:
- Publish a page in Sitecore
- Trigger revalidation via the context menu
- Check that the page gets purged from Netlify's cache within a few seconds
- Verify in Netlify's dashboard that cache tag purges are happening
-
Keep an Eye on It: Watch for any edge cases with path normalization or cache tag matching. You might need to tweak the buildCacheTag function based on your URL structure.
That's it! You'll have a cache invalidation system that works with Netlify's edge network while keeping all the performance benefits of CDN caching.