How to revalidate static pages in next.js with webhooks

5 minute read

Webhooks let Marble keep your site fresh the moment content changes. Instead of waiting on a full rebuild or manually refreshing caches, Marble sends a signed HTTP request whenever a post is published, updated, or deleted. Your Next.js app can catch that event, verify it, and immediately revalidate the right pages or cache tags.

In this guide, we’ll walk through a minimal, secure example for the Next.js App Router using revalidatePath and revalidateTag. By the end, you’ll have an endpoint that automatically keeps your blog up to date: no manual deploys, no stale content.

If you want a quick intro to webhooks in Marble, read our webhooks announcement. If you want the broader blog setup first, read how to build a blog with Next.js and a headless CMS.

Prerequisites

Before you start, make sure you have the following:

  1. A Next.js app using the App Router. This guide uses App Router APIs like revalidatePath.

  2. A Marble workspace. You need a workspace with posts and access to the Webhooks settings page.

  3. A webhook endpoint URL. In production this might be https://yourdomain.com/api/revalidate. For local testing, use a tunnel such as ngrok so Marble can reach your local app.

  4. A webhook secret. Marble generates this for each webhook. Your app uses it to verify the request signature.

Add the secret to your Next.js environment:

# .env.local
MARBLE_WEBHOOK_SECRET=your_webhook_secret_here

What Marble sends

JSON webhooks receive a stable event envelope. For post events, the event name is available as payload.type, and the post data is available as payload.data.

{
  "id": "evt_123",
  "type": "post.published",
  "createdAt": "2026-05-22T14:42:31.120Z",
  "workspaceId": "org_123",
  "resource": {
    "type": "post",
    "id": "post_123"
  },
  "actor": {
    "type": "user",
    "id": "user_123"
  },
  "data": {
    "id": "post_123",
    "title": "Getting Started with Marble",
    "slug": "getting-started-with-marble",
    "description": "Learn how to publish your first post with Marble.",
    "coverImage": "https://media.marblecms.com/example.png",
    "status": "published",
    "featured": false,
    "publishedAt": "2026-05-22T14:00:00.000Z",
    "createdAt": "2026-05-21T18:12:09.431Z",
    "updatedAt": "2026-05-22T14:42:30.951Z"
  }
}

Webhook requests also include useful headers:

  • x-marble-event: the event type, such as post.published.

  • x-marble-event-id: the durable workspace event ID.

  • x-marble-delivery-id: the delivery ID for this webhook send.

  • x-marble-timestamp: the timestamp used for the request.

  • x-marble-signature: the HMAC SHA-256 signature for the request body.

Create the webhook types

Create types/webhook.ts with the fields your revalidation handler needs:

export type MarblePostEvent =
  | "post.published"
  | "post.updated"
  | "post.deleted";

export type PostWebhookPayload = {
  id: string;
  type: MarblePostEvent;
  createdAt: string;
  workspaceId: string;
  resource: {
    type: "post";
    id: string;
  } | null;
  actor: {
    type: "user" | "api_key" | "mcp" | "system";
    id: string | null;
  } | null;
  data: {
    id: string;
    slug?: string;
    title?: string;
    description?: string | null;
    coverImage?: string | null;
    status?: string;
    changes?: string[];
  };
};

Verify the webhook signature

Create lib/marble/webhook.ts. The important part is that you verify the signature against the exact raw request body. Do not call request.json() before signature verification.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySignature(
  secret: string,
  signatureHeader: string,
  bodyText: string
) {
  const expectedHex = signatureHeader.replace(/^sha256=/, "");
  const computedHex = createHmac("sha256", secret)
    .update(bodyText)
    .digest("hex");

  const expected = Buffer.from(expectedHex, "hex");
  const computed = Buffer.from(computedHex, "hex");

  if (expected.length !== computed.length) {
    return false;
  }

  return timingSafeEqual(expected, computed);
}

Handle the webhook event

Next, add a handler that decides what to revalidate. This example handles post events and revalidates the blog index plus the individual post page when a slug is present.

import { revalidatePath, revalidateTag } from "next/cache";
import type { PostWebhookPayload } from "@/types/webhook";

export async function handleWebhookEvent(payload: PostWebhookPayload) {
  const event = payload.type;
  const data = payload.data;

  if (event.startsWith("post.")) {
    revalidatePath("/blog");

    if (data.slug) {
      revalidatePath(`/blog/${data.slug}`);
    }

    revalidateTag("posts");

    return {
      revalidated: true,
      now: Date.now(),
      message: "Post event handled",
    };
  }

  return {
    revalidated: false,
    now: Date.now(),
    message: "Event ignored",
  };
}

revalidatePath("/blog") marks the blog index as stale. The next visitor gets fresh content. revalidateTag("posts") is useful if your server data fetching uses tags such as fetch(url, { next: { tags: ["posts"] } }).

Create the route handler

Now create app/api/revalidate/route.ts:

import { NextResponse } from "next/server";
import { handleWebhookEvent, verifySignature } from "@/lib/marble/webhook";
import type { PostWebhookPayload } from "@/types/webhook";

export async function POST(request: Request) {
  const signature = request.headers.get("x-marble-signature");
  const secret = process.env.MARBLE_WEBHOOK_SECRET;

  if (!secret || !signature) {
    return NextResponse.json(
      { error: "Secret or signature missing" },
      { status: 400 }
    );
  }

  const bodyText = await request.text();

  if (!verifySignature(secret, signature, bodyText)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  let payload: PostWebhookPayload;

  try {
    payload = JSON.parse(bodyText) as PostWebhookPayload;
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  if (!payload.type || !payload.data) {
    return NextResponse.json(
      { error: "Invalid payload structure" },
      { status: 400 }
    );
  }

  try {
    const result = await handleWebhookEvent(payload);
    return NextResponse.json(result);
  } catch {
    return NextResponse.json(
      { error: "Failed to process webhook" },
      { status: 500 }
    );
  }
}

Create the webhook in Marble

In your Marble dashboard, go to SettingsWebhooks. Create a new webhook with your deployed endpoint URL, choose JSON as the format, and select the post events you care about, such as post.published, post.updated, and post.deleted.

After creating the webhook, copy its secret and add it to your deployment environment as MARBLE_WEBHOOK_SECRET.

Local testing

For local testing, run your Next.js app and expose it with a tunnel:

ngrok http 3000

Use the generated HTTPS URL as your Marble webhook endpoint, for example:

https://example.ngrok-free.app/api/revalidate

Then publish or update a post in Marble and watch your local server logs.

Wrapping up

In a few steps, we built a webhook endpoint that:

  • reads the raw request body,

  • verifies the Marble signature,

  • parses the current webhook envelope,

  • and revalidates the affected Next.js routes.

That gives you a blog that can stay static for readers while still updating quickly when content changes in Marble.

Next steps

Try Marble today.

A simpler way to publish articles and manage your blog.