Tech Verse Logo
Enable dark mode
Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

6 min read

The Real Cost of Time-Based Revalidation

If you've built a content platform with Next.js over the past few years, you've probably relied on time-based Incremental Static Regeneration (ISR). You set export const revalidate = 60; at the top of your page component, and Next.js regenerates static HTML behind the scenes when a request comes in after 60 seconds. It works well enough for simple blogs, but it falls apart in high-traffic production environments.

Setting static timers forces a trade-off between server load and content freshness. A 10-second window keeps content reasonably fresh, but on a busy site with 10,000 distinct article routes, your Node.js workers spend thousands of CPU cycles rendering identical HTML payloads for minor updates. A 3600-second window saves your CPU, but your editors will call you complaining that an urgent typo fix isn't showing up on the live site.

Next.js 16 and React 19 refine how the Data Cache operates alongside the App Router. Moving from naive time-based revalidation to on-demand, tag-based revalidation using back-end webhooks drops page generation requests by 90% while ensuring content updates instantly across your network.

Time-Based vs. Cache Tag Revalidation

In Next.js 16, fetching data inside Server Components uses an extended version of the native fetch API. When you send a request to your API, Next.js intercepts it and stores the response in the persistent Data Cache. Instead of setting an expiration timer, you assign tags to the cached resource.

Time-based revalidation binds the lifecycle of a cached response to time elapsed on the server clock. Tag-based revalidation binds the response to an abstract label. Here is how you fetch article data in Next.js 16 with cache tags attached:

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';

interface Post {
  id: number;
  slug: string;
  title: string;
  content: string;
}

async function getPost(slug: string): Promise<Post | null> {
  const res = await fetch(`https://cms.example.com/api/v1/posts/${slug}`, {
    headers: {
      'Accept': 'application/json',
      'X-Internal-Token': process.env.INTERNAL_API_TOKEN || '',
    },
    next: {
      tags: [`post:${slug}`, 'posts'],
    },
  });

  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Failed to fetch post: ${res.statusText}`);

  return res.json();
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <article className="max-w-2xl mx-auto py-8">
      <h1 className="text-3xl font-bold">{post.title}</h1>
      <div className="mt-4 prose" dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Notice the next.tags array in the fetch options. We pass two distinct tags: posts (a general collection tag) and post:${slug} (an entity-specific tag). If you update an individual article, you purge post:my-first-post. If you publish a brand new article and need category listing pages to clear their cache, you purge posts.

Triggering On-Demand Purges from Laravel 12

To purge cache tags on demand, your CMS needs to notify Next.js whenever data changes. On PHP 8.3 running Laravel 12, Eloquent model observers or dispatchable events provide the cleanest integration point. When a writer saves an article in your backend, Laravel fires a webhook to a Next.js Route Handler.

Security is critical here. You don't want unauthorized third parties triggering arbitrary cache invalidations on your front-end servers. The standard approach is signing payload data using an HMAC signature with SHA-256.

Here is an Eloquent Observer in Laravel 12 that sends signed invalidation webhooks whenever a Post model is saved or deleted:

<?php

namespace App\Observers;

use App\Models\Post;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class PostObserver
{
    /**
     * Handle the Post "saved" event.
     */
    public function saved(Post $post): void
    {
        $this->dispatchRevalidation(['post:' . $post->slug, 'posts']);
    }

    /**
     * Handle the Post "deleted" event.
     */
    public function deleted(Post $post): void
    {
        $this->dispatchRevalidation(['post:' . $post->slug, 'posts']);
    }

    /**
     * Send signed payload to Next.js webhook endpoint.
     *
     * @param array<string> $tags
     */
    private function dispatchRevalidation(array $tags): void
    {
        $endpoint = config('services.nextjs.webhook_url');
        $secret = config('services.nextjs.revalidate_secret');

        if (!$endpoint || !$secret) {
            return;
        }

        $payload = json_encode([
            'tags' => $tags,
            'timestamp' => time(),
        ]);

        $signature = hash_hmac('sha256', $payload, $secret);

        $response = Http::withHeaders([
            'Content-Type' => 'application/json',
            'X-Signature' => $signature,
        ])->withBody($payload, 'application/json')
          ->post($endpoint);

        if ($response->failed()) {
            Log::error('Next.js cache revalidation failed', [
                'status' => $response->status(),
                'body' => $response->body(),
            ]);
        }
    }
}

Building the Route Handler in Next.js 16

On the Next.js side, create a Route Handler at app/api/revalidate/route.ts. This endpoint verifies the incoming signature from Laravel, extracts the array of tags, and executes revalidateTag() from next/cache.

Here is the Route Handler implementation:

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
import crypto from 'node:crypto';

export async function POST(request: NextRequest) {
  const secret = process.env.REVALIDATE_SECRET;
  if (!secret) {
    return NextResponse.json({ message: 'Server secret not configured' }, { status: 500 });
  }

  const rawBody = await request.text();
  const signature = request.headers.get('X-Signature');

  if (!signature) {
    return NextResponse.json({ message: 'Missing signature header' }, { status: 401 });
  }

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const trusted = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );

  if (!trusted) {
    return NextResponse.json({ message: 'Invalid signature' }, { status: 403 });
  }

  try {
    const data = JSON.parse(rawBody);
    const tags: string[] = data.tags || [];

    if (!Array.isArray(tags) || tags.length === 0) {
      return NextResponse.json({ message: 'No tags provided' }, { status: 400 });
    }

    for (const tag of tags) {
      revalidateTag(tag);
    }

    return NextResponse.json({
      revalidated: true,
      tags,
      now: Date.now(),
    });
  } catch (err) {
    return NextResponse.json({ message: 'Error parsing request body' }, { status: 400 });
  }
}

Using crypto.timingSafeEqual prevents timing attacks when comparing HMAC signatures. Once validation succeeds, iterating over revalidateTag(tag) immediately marks the corresponding entries in Next.js Data Cache as stale.

Production Gotchas and Debugging Stale Cache

When you deploy this architecture to production, you will run into edge cases where pages appear stale even after the webhook returns a 200 OK status code. Here are the main traps to watch for and how to resolve them.

1. Stale-While-Revalidate Behavior

Calling revalidateTag() does not immediately render new static HTML on the server disk. Instead, it marks the cached data as expired. The next user who visits the URL will still receive the existing stale HTML, but that request triggers a background regeneration. Only the second visitor sees the updated content.

If you need instant updates on the very next visit, combine revalidateTag() with dynamic routing or ensure your reverse proxy handles cache-control headers correctly. Alternatively, call revalidatePath('/posts/' + slug) alongside your tag invalidations to expire the route rendering layer as well as the underlying fetch data cache.

2. Multi-Instance Deployments and Standalone Mode

When running Next.js inside Docker containers using output: 'standalone' across multiple nodes (such as an AWS ECS cluster or Kubernetes deployment), each container maintains its own local in-memory Data Cache by default. Calling revalidateTag() hits only one node behind your load balancer. The other nodes will continue serving stale content.

To fix this, configure a shared cache handler using Redis or Memcached. In Next.js 16, set up a custom cacheHandler in your next.config.js file that directs fetch cache operations to a centralized Redis instance like AWS ElastiCache.

3. Edge CDN Caching Headers

If your application sits behind Cloudflare, Fastly, or AWS CloudFront, your edge network might cache the rendered HTML responses based on Cache-Control response headers. When Next.js serves a page generated via ISR, it sets headers like s-maxage=31536000, stale-while-revalidate.

Even if Next.js purges its internal cache via webhook, the CDN edge server will continue serving its cached copy until the CDN cache expires or you trigger a Cloudflare cache purge API request from Laravel at the same time you send your revalidation webhook.

4. Debugging Cache Hits and Misses

To inspect cache behavior in development or staging environments, enable logging in next.config.js:

// next.config.js
module.exports = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
};

This setting prints server terminal logs showing whether each fetch request resulted in a HIT, MISS, or SKIP, along with the specific cache tags associated with the request. Inspecting these logs during test webhook dispatches helps you catch missing tags or incorrect route configurations before hitting production.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Laravel API Versioning: URI vs Header Strategies

    Laravel API Versioning: URI vs Header Strategies

    Server Components vs Client Components: Boundary Rules

    Server Components vs Client Components: Boundary Rules

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Form Request Architecture: Rules, Hooks, & Arrays

    Laravel Form Request Architecture: Rules, Hooks, & Arrays

    Prevent Data Leaks in Laravel API Resources

    Prevent Data Leaks in Laravel API Resources

    Advanced Laravel 12 API Rate Limiting and Tiered Limits

    Advanced Laravel 12 API Rate Limiting and Tiered Limits

    Laravel S3 Signed URLs and Private Filesystem Storage

    Laravel S3 Signed URLs and Private Filesystem Storage

    Laravel Horizon Monitoring: Setup and Metrics

    Laravel Horizon Monitoring: Setup and Metrics