Next.js ISR + Netlify Cache Tag Purge for Sitecore Site Pages

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:

  1. 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.

  2. 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.

  3. 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:

  1. Add Middleware: Create middleware.ts in the root of your Next.js app. This automatically stamps cache tags on all HTML routes.

  2. Create Revalidation API: Add pages/api/revalidate.ts with the revalidation logic that calls Netlify's purge API.

  3. 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
  4. Update PowerShell Script: Replace your existing Sitecore PowerShell script with the version above, and update the REVALIDATE_SECRET value.

  5. 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
  6. 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.

Related Posts

Exposing Site Settings from the Settings item in a Headless JSS Next.js Application

In this blog post, we will explore a common requirement in Sitecore Headless SXA, exposing site-specific settings and global data to the front end when working with a JSS headless site. ## Problem S

Read More

Handling Sitecore CM and IdentityServer Behind a Proxy or CDN in an Azure Environment

Recently, while working with a Sitecore 10.4 deployment in an Azure environment, we encountered an interesting challenge: handling Sitecore Content Management (CM) and IdentityServer (SI) behind a pr

Read More

Leveraging Sitecore Search SDK Starter Kit into Your Sitecore Next.js Solution [Part 2]

In this blog post, I'll walk you through the process of smoothly leveraging the Search SDK Starter Kit into a Sitecore Next.js solution. ## Prerequisites Before proceeding, ensure the following prer

Read More