# How to revalidate static pages in next.js with webhooks

September 22, 2025

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](/content/blog/introducing-webhooks/index.html). If you want the broader blog setup first, read [how to build a blog with Next.js and a headless CMS](/content/blog/nextjs-headless-cms-blog/index.html).

## 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 **Settings** → **Webhooks**. 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

- Check out the [Marble Next.js example](https://github.com/usemarble/nextjs-example) to try a fuller setup locally.

- Read the [webhooks documentation](https://docs.marblecms.com/guides/features/webhooks) for all event types and payload details.

- Read [how to build a blog with Next.js and a headless CMS](/content/blog/nextjs-headless-cms-blog/index.html) if you want the full blog structure around this endpoint.
