Keep your Next.js site in sync with your CMS content in real time. This guide covers how Lynkow webhooks work, how to verify HMAC signatures, and how to build a secure revalidation endpoint that invalidates the right pages when content changes.
Prerequisites
You should have the Lynkow SDK installed and a client initialized. If not, see Guide 1: Quick Start.
// lib/lynkow.ts
import { createClient } from 'lynkow'
export const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
fetchOptions: {
next: { revalidate: 60 },
},
})1. How Lynkow webhooks work
When something changes in your Lynkow site (a post is published, a review is approved, a redirect is created), the API can send an HTTP POST request to any URL you configure. This is a webhook.
The flow:
Editor publishes article in Lynkow admin
|
v
Lynkow API triggers "content.published" event
|
v
Finds all enabled webhooks for this site that listen to this event
|
v
Sends POST requests in parallel (Promise.allSettled)
|
v
Your Next.js route handler receives the payload
|
v
Verifies the HMAC signature
|
v
Calls revalidatePath() or revalidateTag() to purge the ISR cache
|
v
Next visitor sees updated contentKey characteristics:
Fire-and-forget -- Lynkow sends the webhook and does not retry on failure
Parallel delivery -- if multiple webhooks listen to the same event, they are triggered simultaneously via
Promise.allSettled30-second timeout -- your endpoint must respond within 30 seconds or the request is aborted
HMAC-SHA256 signing -- payloads are signed with your webhook secret so you can verify authenticity
Lightweight payloads -- webhook payloads contain only essential fields (IDs, slugs, status) to keep request sizes small; fetch full data from the API if needed
Understanding the cache layers
There are multiple cache layers between a visitor and your content. Each is handled differently:
Visitor → [Your CDN] → Next.js → [Next.js Cache] → SDK → [Cloudflare CDN] → Lynkow API
(optional) (revalidate:60) (cache:false) (auto-purged)Layer | TTL | Invalidation | Your action |
|---|---|---|---|
Cloudflare API cache | 5 min | Purged automatically by Lynkow when content changes | None required |
Next.js fetch cache |
|
| Set up the webhook handler (this guide) |
SDK in-memory cache | Disabled by default | N/A | None required (disabled since SDK v3.9) |
Your own CDN (Cloudflare, Vercel, etc.) | Varies | Depends on your CDN config |
With cache: false on the SDK client and a webhook handler, the flow becomes:
Editor publishes content in Lynkow admin
Lynkow automatically purges the Cloudflare CDN cache for that site's API responses
Lynkow sends the webhook to your Next.js endpoint
Your handler calls
revalidatePath()orrevalidateTag()Next visitor triggers a re-render, the SDK calls
fetch()(no in-memory cache), Cloudflare returns fresh dataContent is updated within seconds
2. Webhook events
Every webhook event follows the format {resource}.{action}. Here are the events relevant to a content site:
Content events
Event | Triggered when |
|---|---|
| A new content item is saved as draft |
| An existing content item is edited |
| Content transitions to published status |
| Content is archived |
| Content is permanently deleted |
| Content is copied to a new locale |
| Multiple items are published at once |
| Multiple items are archived at once |
| Multiple items are deleted at once |
Category and tag events
Event | Triggered when |
|---|---|
| A new category is created |
| A category name, slug, or parent changes |
| A category is removed |
| A category is copied to a new locale |
| A new tag is created |
| A tag is renamed |
| A tag is removed |
Form events
Event | Triggered when |
|---|---|
| A new form is created |
| A form's schema or settings change |
| A form is removed |
| A visitor submits a form |
Review events
Event | Triggered when |
|---|---|
| A new review is submitted (visitor or admin) |
| A review's status changes (approved, rejected) |
| A review is removed |
| Multiple reviews are moderated at once |
| Multiple reviews are deleted at once |
Media events
Event | Triggered when |
|---|---|
| A new file is uploaded |
| File metadata (alt text, title) is changed |
| A file is replaced with a new version |
| A file is moved to trash |
| A file is restored from trash |
| A file is permanently deleted |
Site block events
Event | Triggered when |
|---|---|
| A new site block is created |
| A site block's data changes |
| A site block is published (goes live) |
| A site block is taken offline |
| A site block is removed |
Other events
Event | Triggered when |
|---|---|
| A new redirect rule is added |
| A redirect is modified |
| A redirect is removed |
| A custom sitemap entry is added |
| A sitemap entry is modified |
| A sitemap entry is removed |
3. Webhook payload structure
Every webhook POST request has this JSON body:
{
event: string // e.g. "content.published"
timestamp: string // ISO 8601, e.g. "2024-03-15T10:30:00.000Z"
data: {
// Resource-specific fields
}
}Content payload example
{
"event": "content.published",
"timestamp": "2024-03-15T10:30:00.000Z",
"data": {
"id": 42,
"slug": "getting-started-with-lynkow",
"title": "Getting Started with Lynkow",
"type": "article",
"status": "published",
"locale": "en",
"path": "/blog/getting-started-with-lynkow",
"authorId": 1,
"categoryId": 5,
"translationGroupId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"createdAt": "2024-03-10T08:00:00.000Z",
"updatedAt": "2024-03-15T10:30:00.000Z",
"publishedAt": "2024-03-15T10:30:00.000Z"
}
}Note that payload bodies are intentionally lightweight. They include IDs, slugs, and status but exclude large fields like body, meta, and excerpt. Call the Lynkow API to fetch the full resource if needed.
Bulk event payload example
{
"event": "content.bulk_published",
"timestamp": "2024-03-15T10:35:00.000Z",
"data": {
"count": 3,
"ids": [42, 43, 44]
}
}4. Setting up a webhook in the admin dashboard
Open your Lynkow admin dashboard
Navigate to Settings > Webhooks
Click Add webhook
Fill in the configuration:
Name -- A descriptive label (e.g., "Next.js revalidation")
URL -- Your endpoint URL (e.g.,
https://example.com/api/revalidate)Secret -- A strong random string for HMAC signing (e.g., generate with
openssl rand -hex 32)Events -- Select which events should trigger this webhook
Custom headers -- Optional extra headers to send with each request
Toggle Enabled to activate the webhook
Click Save
Store the secret in your Next.js environment:
# .env.local
LYNKOW_WEBHOOK_SECRET=your-webhook-secret-here5. Next.js Route Handler
Create a Route Handler that receives webhook payloads, verifies the signature, and triggers on-demand revalidation:
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'
import crypto from 'node:crypto'
// Force Node.js runtime — node:crypto is not available in Edge Runtime (Vercel)
export const runtime = 'nodejs'
const WEBHOOK_SECRET = process.env.LYNKOW_WEBHOOK_SECRET
interface WebhookPayload {
event: string
timestamp: string
data: Record<string, any>
}
export async function POST(request: NextRequest) {
// 0. Guard: webhook secret must be configured
if (!WEBHOOK_SECRET) {
console.error('[Webhook] LYNKOW_WEBHOOK_SECRET is not configured')
return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 })
}
// 1. Read the raw body for signature verification
const rawBody = await request.text()
// 2. Verify the HMAC-SHA256 signature
const signature = request.headers.get('X-Webhook-Signature')
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 401 })
}
const expectedSignature = `sha256=${crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')}`
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
// 3. Parse the payload
let payload: WebhookPayload
try {
payload = JSON.parse(rawBody)
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const { event, data } = payload
// 4. Revalidate based on event type
const revalidated = handleRevalidation(event, data)
return NextResponse.json({
revalidated: true,
event,
...revalidated,
})
}Vercel / Edge Runtime: The
export const runtime = 'nodejs'line is critical. Without it, Vercel may run your Route Handler in Edge Runtime, wherenode:cryptois not available. The handler will crash silently with a 500 error and no explicit log.
6. Verifying the HMAC-SHA256 signature
Lynkow signs every webhook payload using HMAC-SHA256. The signature is sent in the X-Webhook-Signature header with the format sha256={hex_digest}.
The signature is computed from the JSON-stringified webhook payload (the entire { event, timestamp, data } object) using the secret you configured.
Always use crypto.timingSafeEqual for comparing signatures. A naive string comparison (===) is vulnerable to timing attacks, where an attacker can guess the secret one character at a time by measuring response times.
import crypto from 'node:crypto'
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = `sha256=${crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')}`
// Both buffers must have the same length for timingSafeEqual
if (signature.length !== expected.length) {
return false
}
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}Important: You must compare against the raw request body string, not a re-serialized version. JSON serialization is not deterministic (key order, spacing), so JSON.stringify(JSON.parse(body)) may produce a different string than the original.
7. Mapping events to revalidation targets
The core decision is: for each webhook event, what pages or data need to be refreshed?
Path-based revalidation with revalidatePath()
Use revalidatePath() when you know the exact URL of the page that changed:
// Revalidate a specific blog post
revalidatePath(`/blog/${data.slug}`)
// Revalidate the blog listing page
revalidatePath('/blog')
// Revalidate the entire site (use sparingly)
revalidatePath('/', 'layout')Tag-based revalidation with revalidateTag()
Use revalidateTag() when multiple pages depend on the same data source. This requires that your fetch calls include cache tags:
// In your data fetching code, tag the fetch:
const { data: articles } = await lynkow.contents.list({
limit: 10,
fetchOptions: {
next: { tags: ['articles'] },
},
})
// In the webhook handler, invalidate by tag:
revalidateTag('articles', 'default')Next.js 16 breaking change:
revalidateTag()now requires a second argument (the cache kind). UserevalidateTag(tag, 'default'). On Next.js 15 and earlier,revalidateTag(tag)works without it.
Tag-based revalidation is cleaner because you do not need to know every page URL that uses a piece of data.
8. Revalidation strategy
Here is a complete mapping from webhook events to revalidation actions:
function handleRevalidation(
event: string,
data: Record<string, any>
): { paths: string[]; tags: string[] } {
const paths: string[] = []
const tags: string[] = []
switch (event) {
// ── Content ──────────────────────────────────────
case 'content.published':
case 'content.updated':
case 'content.archived':
case 'content.deleted': {
// Revalidate the individual page
if (data.path) {
paths.push(data.path)
} else if (data.slug) {
paths.push(`/blog/${data.slug}`)
}
// Revalidate listing pages
paths.push('/blog')
paths.push('/')
// Invalidate content cache tags
tags.push('articles')
tags.push('latest-articles')
break
}
case 'content.created': {
// Draft created -- only refresh listing if you show drafts
// Usually no revalidation needed for drafts
break
}
case 'content.bulk_published':
case 'content.bulk_archived':
case 'content.bulk_deleted': {
// Bulk operations -- revalidate listing pages and content tag
paths.push('/blog')
paths.push('/')
tags.push('articles')
break
}
// ── Categories ───────────────────────────────────
case 'category.created':
case 'category.updated':
case 'category.deleted': {
if (data.slug) {
paths.push(`/blog/category/${data.slug}`)
}
tags.push('categories')
// Category changes may affect article listings
paths.push('/blog')
break
}
// ── Tags ─────────────────────────────────────────
case 'tag.created':
case 'tag.updated':
case 'tag.deleted': {
tags.push('tags')
break
}
// ── Forms ────────────────────────────────────────
case 'form.created':
case 'form.updated':
case 'form.deleted': {
tags.push('forms')
// Revalidate pages that embed this form
if (data.slug) {
paths.push(`/contact`) // adjust based on your routing
}
break
}
case 'form.submitted': {
// Form submissions typically don't need page revalidation
// unless you display submission counts publicly
break
}
// ── Reviews ──────────────────────────────────────
case 'review.created':
case 'review.updated':
case 'review.deleted':
case 'review.bulk_updated':
case 'review.bulk_deleted': {
tags.push('reviews')
// If reviews are displayed on specific pages
paths.push('/reviews')
break
}
// ── Media ────────────────────────────────────────
case 'media.uploaded':
case 'media.updated':
case 'media.replaced':
case 'media.deleted': {
// Media changes usually don't require page revalidation
// The CDN handles image URL caching separately
break
}
// ── Site blocks (navigation, footer, etc.) ───────
case 'site_block.published':
case 'site_block.unpublished':
case 'site_block.updated':
case 'site_block.deleted': {
// Site blocks affect layout-level data (navigation, footer, banners)
// Revalidate the layout to refresh shared components
paths.push('/', )
tags.push('site-blocks')
tags.push(`site-block-${data.slug}`)
break
}
// ── Redirects ────────────────────────────────────
case 'redirect.created':
case 'redirect.updated':
case 'redirect.deleted': {
tags.push('redirects')
break
}
default: {
// Unknown event -- log it but don't fail
console.log(`[Webhook] Unhandled event: ${event}`)
}
}
// Execute revalidation
for (const path of paths) {
revalidatePath(path)
}
for (const tag of tags) {
revalidateTag(tag, 'default')
}
return { paths, tags }
}Adjust the path mappings to match your project's URL structure. The paths above assume a /blog/[slug] pattern, but your routes may differ.
9. Using cache tags with the SDK
To take full advantage of tag-based revalidation, add cache tags to your Lynkow SDK calls using fetchOptions:
// app/blog/page.tsx
import { lynkow } from '@/lib/lynkow'
export default async function BlogPage() {
const { data: articles } = await lynkow.contents.list({
limit: 10,
sort: 'published_at',
order: 'desc',
fetchOptions: {
next: { tags: ['articles', 'latest-articles'] },
},
})
return (
<main>
{articles.map((article) => (
<article key={article.id}>
<h2>{article.title}</h2>
</article>
))}
</main>
)
}// app/blog/[slug]/page.tsx
import { lynkow } from '@/lib/lynkow'
interface Props {
params: Promise<{ slug: string }>
}
export default async function ArticlePage({ params }: Props) {
const { slug } = await params
const article = await lynkow.contents.getBySlug(slug, {
fetchOptions: {
next: { tags: ['articles', `article-${slug}`] },
},
})
return (
<article>
<h1>{article.title}</h1>
</article>
)
}// Shared layout data (navigation, footer)
// app/layout.tsx
import { lynkow } from '@/lib/lynkow'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const navigation = await lynkow.siteBlocks.getBySlug('navigation', {
fetchOptions: {
next: { tags: ['site-blocks', 'site-block-navigation'] },
},
})
const footer = await lynkow.siteBlocks.getBySlug('footer', {
fetchOptions: {
next: { tags: ['site-blocks', 'site-block-footer'] },
},
})
return (
<html>
<body>
<nav>{/* render navigation data */}</nav>
<main>{children}</main>
<footer>{/* render footer data */}</footer>
</body>
</html>
)
}When a site_block.published event arrives for the "navigation" block, the handler calls revalidateTag('site-block-navigation'), which invalidates every page that included that tag in its fetch -- effectively refreshing the navigation across your entire site without revalidating each page individually.
10. Securing the endpoint
Beyond HMAC verification, add these safeguards:
Reject missing or empty secrets
export async function POST(request: NextRequest) {
if (!WEBHOOK_SECRET) {
console.error('[Webhook] LYNKOW_WEBHOOK_SECRET is not configured')
return NextResponse.json(
{ error: 'Webhook secret not configured' },
{ status: 500 }
)
}
// ... rest of handler
}Reject stale timestamps
Prevent replay attacks by rejecting payloads older than 5 minutes:
function isTimestampValid(timestamp: string, toleranceMs = 5 * 60 * 1000): boolean {
const payloadTime = new Date(timestamp).getTime()
const now = Date.now()
return Math.abs(now - payloadTime) < toleranceMs
}
// In the handler, after signature verification:
const payload: WebhookPayload = JSON.parse(rawBody)
if (!isTimestampValid(payload.timestamp)) {
return NextResponse.json({ error: 'Stale timestamp' }, { status: 401 })
}Rate limiting
For production, consider adding rate limiting to prevent abuse. A simple approach using a counter:
const recentRequests = new Map<string, number>()
const RATE_LIMIT = 60 // max requests per minute
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const now = Math.floor(Date.now() / 60000) // minute bucket
const key = `${ip}-${now}`
const count = recentRequests.get(key) || 0
if (count >= RATE_LIMIT) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
}
recentRequests.set(key, count + 1)
// Clean old entries
for (const [k] of recentRequests) {
if (!k.endsWith(`-${now}`)) recentRequests.delete(k)
}
// ... rest of handler
}11. SDK cache and framework caching
The SDK's in-memory cache is disabled by default. Every SDK call goes through fetch(), which your framework (Next.js, Nuxt, Astro) intercepts and manages with its own caching layer. This is the recommended setup for server-side rendering.
No configuration is needed for webhook revalidation to work correctly. When your handler calls revalidatePath() or revalidateTag(), Next.js purges its cache, and the next render fetches fresh data from the Lynkow API.
If you are building a browser-only SPA without framework caching, you can opt into the SDK's localStorage cache:
const lynkow = createClient({
siteId: 'your-site-uuid',
cache: true, // Enable localStorage cache for browser SPAs
})12. Complete webhook handler
Here is the full, production-ready webhook handler combining everything from this guide:
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'
import crypto from 'node:crypto'
// Force Node.js runtime — node:crypto is not available in Edge Runtime (Vercel)
export const runtime = 'nodejs'
// ── Configuration ──────────────────────────────────
const WEBHOOK_SECRET = process.env.LYNKOW_WEBHOOK_SECRET
interface WebhookPayload {
event: string
timestamp: string
data: Record<string, any>
}
// ── Signature verification ─────────────────────────
function verifySignature(rawBody: string, signature: string | null): boolean {
if (!signature || !WEBHOOK_SECRET) return false
const expected = `sha256=${crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')}`
if (signature.length !== expected.length) return false
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
// ── Timestamp validation ───────────────────────────
function isTimestampValid(timestamp: string, toleranceMs = 5 * 60 * 1000): boolean {
const payloadTime = new Date(timestamp).getTime()
if (Number.isNaN(payloadTime)) return false
return Math.abs(Date.now() - payloadTime) < toleranceMs
}
// ── Revalidation logic ─────────────────────────────
function handleRevalidation(
event: string,
data: Record<string, any>
): { paths: string[]; tags: string[] } {
const paths: string[] = []
const tags: string[] = []
switch (event) {
// ── Content ────────────────────────────────────
case 'content.published':
case 'content.updated':
case 'content.archived':
case 'content.deleted': {
if (data.path) {
paths.push(data.path)
} else if (data.slug) {
paths.push(`/blog/${data.slug}`)
}
paths.push('/blog')
paths.push('/')
tags.push('articles')
tags.push('latest-articles')
break
}
case 'content.translated': {
if (data.slug) {
paths.push(`/blog/${data.slug}`)
}
paths.push('/blog')
tags.push('articles')
break
}
case 'content.created': {
// Drafts don't appear on the public site -- no revalidation needed
break
}
case 'content.bulk_published':
case 'content.bulk_archived':
case 'content.bulk_deleted': {
paths.push('/blog')
paths.push('/')
tags.push('articles')
tags.push('latest-articles')
break
}
// ── Categories ─────────────────────────────────
case 'category.created':
case 'category.updated':
case 'category.deleted':
case 'category.translated': {
if (data.slug) {
paths.push(`/blog/category/${data.slug}`)
}
paths.push('/blog')
tags.push('categories')
break
}
// ── Tags ───────────────────────────────────────
case 'tag.created':
case 'tag.updated':
case 'tag.deleted': {
tags.push('tags')
break
}
// ── Forms ──────────────────────────────────────
case 'form.created':
case 'form.updated':
case 'form.deleted': {
tags.push('forms')
break
}
case 'form.submitted': {
// No page revalidation needed for submissions
break
}
// ── Reviews ────────────────────────────────────
case 'review.created':
case 'review.updated':
case 'review.deleted':
case 'review.bulk_updated':
case 'review.bulk_deleted': {
tags.push('reviews')
break
}
// ── Media ──────────────────────────────────────
case 'media.uploaded':
case 'media.updated':
case 'media.replaced':
case 'media.trashed':
case 'media.restored':
case 'media.deleted': {
// Media changes are handled by the CDN -- no page revalidation needed
break
}
// ── Site blocks ────────────────────────────────
case 'site_block.created':
case 'site_block.updated':
case 'site_block.published':
case 'site_block.unpublished':
case 'site_block.deleted':
case 'site_block.schema_updated':
case 'site_block.translation_created': {
// Site blocks affect layout-level data
paths.push('/')
tags.push('site-blocks')
if (data.slug) {
tags.push(`site-block-${data.slug}`)
}
break
}
// ── Redirects ──────────────────────────────────
case 'redirect.created':
case 'redirect.updated':
case 'redirect.deleted':
case 'redirect.bulk_deleted': {
tags.push('redirects')
break
}
// ── Sitemap ────────────────────────────────────
case 'sitemap_entry.created':
case 'sitemap_entry.updated':
case 'sitemap_entry.deleted': {
tags.push('sitemap')
break
}
default: {
console.log(`[Webhook] Unhandled event: ${event}`)
}
}
// Execute revalidation
for (const path of paths) {
try {
revalidatePath(path)
} catch (err) {
console.error(`[Webhook] Failed to revalidate path "${path}":`, err)
}
}
for (const tag of tags) {
try {
revalidateTag(tag, 'default')
} catch (err) {
console.error(`[Webhook] Failed to revalidate tag "${tag}":`, err)
}
}
return { paths, tags }
}
// ── Route handler ──────────────────────────────────
export async function POST(request: NextRequest) {
// Guard: webhook secret must be configured
if (!WEBHOOK_SECRET) {
console.error('[Webhook] LYNKOW_WEBHOOK_SECRET is not configured')
return NextResponse.json(
{ error: 'Server misconfigured' },
{ status: 500 }
)
}
// 1. Read raw body
const rawBody = await request.text()
// 2. Verify HMAC-SHA256 signature
const signature = request.headers.get('X-Webhook-Signature')
if (!verifySignature(rawBody, signature)) {
console.warn('[Webhook] Invalid or missing signature')
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
// 3. Parse payload
let payload: WebhookPayload
try {
payload = JSON.parse(rawBody)
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
// 4. Reject stale payloads (older than 5 minutes)
if (!isTimestampValid(payload.timestamp)) {
console.warn('[Webhook] Stale timestamp:', payload.timestamp)
return NextResponse.json({ error: 'Stale timestamp' }, { status: 401 })
}
// 5. Handle revalidation
const { event, data } = payload
console.log(`[Webhook] Received event: ${event}`)
const result = handleRevalidation(event, data)
console.log(
`[Webhook] Revalidated ${result.paths.length} paths, ${result.tags.length} tags`
)
return NextResponse.json({
revalidated: true,
event,
paths: result.paths,
tags: result.tags,
})
}Putting it all together
Environment setup
# .env.local
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-id
LYNKOW_WEBHOOK_SECRET=your-32-char-hex-secretFile structure
app/
├── api/
│ └── revalidate/
│ └── route.ts # Webhook handler (this guide)
├── blog/
│ ├── page.tsx # Blog listing (tagged with 'articles')
│ ├── [slug]/
│ │ └── page.tsx # Article page (tagged with 'articles')
│ └── category/
│ └── [slug]/
│ └── page.tsx # Category page (tagged with 'categories')
└── layout.tsx # Root layout (tagged with 'site-blocks')How ISR + webhooks work together
Without webhooks, your Next.js site uses time-based ISR (revalidate: 60), meaning pages may be up to 60 seconds stale after a content change.
With webhooks, you get near-instant updates:
Editor publishes an article in Lynkow
Lynkow sends
content.publishedwebhook within millisecondsYour handler calls
revalidatePath('/blog/my-article')andrevalidateTag('articles')Next.js marks those cached pages as stale
The next visitor triggers a background regeneration
Subsequent visitors see the updated content
You should keep the time-based revalidate as a safety net. If a webhook is lost (network issue, deployment), the time-based fallback ensures content is never stale for longer than the revalidation interval.
Testing locally
Use a tunneling tool to expose your local Next.js server to the internet:
# Using ngrok
ngrok http 3000Then configure the webhook URL in the Lynkow admin dashboard to point to your ngrok URL:
https://abc123.ngrok.io/api/revalidatePublish or update a piece of content and check your terminal for the webhook logs.
Debugging
If webhooks are not working:
Check webhook logs -- In the Lynkow admin, go to Settings > Webhooks and click on your webhook to see delivery logs with status codes and response bodies
Verify the secret -- Make sure
LYNKOW_WEBHOOK_SECRETin your.env.localmatches the secret configured in the admin dashboardCheck the endpoint URL -- Ensure it is publicly accessible (not
localhost) and uses HTTPSCheck the response -- Your handler must respond within 30 seconds or Lynkow will abort the request
Check event selection -- Make sure the events you need are checked in the webhook configuration
Check the SDK cache -- If you see
CF-Cache-Status: MISSand correct webhook logs but content is still stale, make sure you are usingcache: falsein yourcreateClient()call (see section 11)
Cloudflare CDN and stale HTML pages
Note: The Lynkow API's own Cloudflare cache (
api.lynkow.com) is purged automatically when content changes. You do not need to do anything for that layer. This section is only relevant if your Next.js site itself is behind Cloudflare's proxy (orange cloud enabled on your domain).
If your site is behind Cloudflare, there is an additional cache layer: Cloudflare caches the HTML pages served by Next.js. Your webhook handler calls revalidatePath() which purges the Next.js cache, but Cloudflare continues serving its cached HTML copy until it expires.
Diagnosing the issue
curl -I https://your-site.com/blog/my-articleHeader | Value | Meaning |
|---|---|---|
|
| Cloudflare served from edge cache (potentially stale) |
|
| Cloudflare fetched from origin (fresh) |
|
| Cloudflare did not cache this response |
If you see HIT with a high Age after triggering a webhook, Cloudflare is serving stale HTML.
Option 1: Bypass Cloudflare cache for HTML pages (recommended)
The simplest approach. Let Cloudflare cache static assets (JS, CSS, images) but always go to origin for HTML:
Go to Caching > Cache Rules in your Cloudflare dashboard
Create a rule:
When:
URI Path does not match\.(js|css|png|jpg|jpeg|gif|svg|woff2?|ico|webp|avif|woff|ttf)$Then: Cache eligibility: Bypass cache
Your HTML pages always come from Next.js (which has its own cache controlled by revalidate + webhooks), while static assets are still served from Cloudflare's edge.
Option 2: Short Edge TTL for HTML pages
If you want Cloudflare to cache HTML for performance:
Go to Caching > Cache Rules
Create a rule for your HTML pages with Edge TTL: 60 seconds
Content will be at most 60 seconds stale after a change, which is acceptable for most sites.
Option 3: Purge Cloudflare from the webhook handler
For near-instant updates, add a Cloudflare purge call to your webhook handler. Use prefixes (not files) for reliable purging with Cache Rules:
async function purgeCloudflareCache(paths: string[]) {
const CF_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID
const CF_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN
const SITE_HOST = process.env.NEXT_PUBLIC_SITE_HOST // e.g. "www.example.com"
if (!CF_ZONE_ID || !CF_API_TOKEN || !SITE_HOST) return
const prefixes = paths.map((path) => `${SITE_HOST}${path}`)
await fetch(
`https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ prefixes }),
}
)
}Then call it in your revalidation handler after revalidatePath():
await purgeCloudflareCache(result.paths)Required environment variables:
# .env.local
CLOUDFLARE_ZONE_ID=your-zone-id
CLOUDFLARE_API_TOKEN=your-api-token
NEXT_PUBLIC_SITE_HOST=www.example.comImportant: Use
prefixes(notfiles) in the purge request. Thefilesmethod does not work reliably when Cloudflare Cache Rules force caching. Theprefixesmethod purges all cached responses that start with the given prefix, including query string variations.