@corpusctl/next
v0.1.0
Published
Next.js helpers for Corpusctl headless CMS — draft preview verification and HMAC-signed publish webhooks for cache revalidation tags
Maintainers
Readme
@corpusctl/next
Next.js helpers for Corpusctl: draft-preview verification and HMAC-signed publish webhooks that map to cache revalidation tags.
The package contains pure logic only; next/headers and next/cache calls
stay in your route handlers — no tight coupling to a Next version, and
everything is unit-testable.
Install
pnpm add @corpusctl/nextPeer dependency: next >= 15.
Draft preview (/api/draft)
verifyDraftRequest enforces two things: the slug must be a relative path
(open-redirect guard) and must actually exist in the CMS:
// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { verifyDraftRequest, draftRejectionMessage } from '@corpusctl/next'
import { ReadClient } from '@corpusctl/client'
const reader = new ReadClient({ tenantId: process.env.TENANT_ID!, edgeUrl: process.env.EDGE_URL! })
export async function GET(request: Request) {
const params = new URL(request.url).searchParams
const decision = await verifyDraftRequest(
{ secret: params.get('secret'), slug: params.get('slug') },
{
secret: process.env.DRAFT_SECRET!,
slugExists: async (slug) => (await reader.resolve(slug.replace(/^\//, ''))) !== undefined,
},
)
if (!decision.ok) {
return new Response(draftRejectionMessage(decision.reason), { status: decision.status })
}
;(await draftMode()).enable()
redirect(decision.redirectTo)
}Publish webhook → cache revalidation
verifyWebhook validates the timestamped HMAC signature (5-minute replay
window) and computes the tags to refresh:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { verifyWebhook } from '@corpusctl/next'
export async function POST(request: Request) {
const rawBody = await request.text()
const result = verifyWebhook(
rawBody,
{
signature: request.headers.get('x-corpusctl-signature'),
timestamp: request.headers.get('x-corpusctl-timestamp'),
},
{ secret: process.env.WEBHOOK_SECRET! },
)
if (!result.ok) return new Response(null, { status: result.status })
for (const tag of result.tags) revalidateTag(tag)
return Response.json({ revalidated: result.tags })
}Subscribe your fetch calls to the right granularity with the tags helpers:
import { tags } from '@corpusctl/next'
fetch(url, { next: { tags: [tags.typeLocale('post', 'en')] } }) // any publish of that type
fetch(url, { next: { tags: [tags.document(doc.documentId)] } }) // only that document
// tags.all() → 'corpusctl' (emergency: refresh everything)Also exported: signPayload, tagsFor(event), checkSlugShape,
DEFAULT_MAX_AGE_MS.
License
MIT
