From 5c00f8e8a0f8ed648658610f3562658c879bd8f1 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 18 May 2026 21:34:51 +0300 Subject: [PATCH] feat: add /api/revalidate webhook for on-demand ISR POST with x-revalidate-secret header and { tag } body calls revalidateTag to purge a collection from the Next.js data cache. Guarded by REVALIDATE_SECRET env var. --- app/api/revalidate/route.ts | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 app/api/revalidate/route.ts diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 0000000..dfdc5bb --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,45 @@ +import { revalidateTag } from 'next/cache'; +import { type NextRequest, NextResponse } from 'next/server'; + +/** + * POST /api/revalidate + * + * Webhook endpoint for on-demand ISR. PocketBase (or any external + * caller) sends this request after mutating CMS content so the + * relevant tag is purged from the Next.js data cache. + * + * Expected body: `{ "tag": "" }` + * Required header: `x-revalidate-secret: ` + */ +export async function POST(request: NextRequest): Promise { + const secret = request.headers.get('x-revalidate-secret'); + + if (secret !== process.env.REVALIDATE_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if ( + typeof body !== 'object' || + body === null || + !('tag' in body) || + typeof (body as Record).tag !== 'string' + ) { + return NextResponse.json({ error: 'Missing or invalid "tag" field' }, { status: 400 }); + } + + const tag = (body as { tag: string }).tag; + + /* Second arg is required by the Next.js 15 type signature; + * "max" means the purge propagates indefinitely — correct for + * an on-demand webhook that has no TTL of its own. */ + revalidateTag(tag, 'max'); + + return NextResponse.json({ revalidated: true, tag }, { status: 200 }); +}