Add secured on-demand revalidation endpoint for posts cache

Public post pages read through the `posts`-tagged unstable_cache wrappers,
so their data is frozen at build time until revalidateTag('posts') runs in
the deployed runtime. A direct-DB seed (pnpm db:seed:posts) writes past the
Payload afterChange hooks (next/cache is stubbed), so nothing busts the tag
in prod and seeded posts never surface publicly.

Add POST /api/revalidate, guarded by a REVALIDATE_SECRET shared secret
(constant-time compare), that calls revalidatePostsListing() to refresh the
home page, all listings, and all article routes at once. Document the env var
in .env.example; it must be set in Vercel for production.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-15 21:24:02 -03:00
parent bcb6c6c014
commit f96863fad1
2 changed files with 60 additions and 0 deletions

View file

@ -10,5 +10,11 @@ PAYLOAD_SECRET=YOUR_SECRET_HERE
BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Shared secret guarding POST /api/revalidate, which busts the `posts` cache tag
# on demand. Needed after a direct-DB seed (`pnpm db:seed:posts`) since that
# bypasses the Payload hooks that normally call revalidateTag. MUST be set in the
# Vercel project env for production. Generate with e.g. `openssl rand -hex 32`.
REVALIDATE_SECRET=YOUR_REVALIDATE_SECRET_HERE
# Note: the automated test suite ignores this file and reads .env.test instead,
# and only ever runs against a database whose name ends with "_test".

View file

@ -0,0 +1,54 @@
import { revalidatePostsListing } from '@/utilities/documents'
import { timingSafeEqual } from 'node:crypto'
import { NextResponse } from 'next/server'
/**
* On-demand revalidation endpoint for content written straight to the database
* (e.g. `pnpm db:seed:posts`), which bypasses the Payload `afterChange` hooks
* that normally call `revalidateTag('posts')`. Because every public post read
* goes through the `posts`-tagged `unstable_cache` wrappers in
* `src/utilities/posts.ts`, a single `revalidateTag('posts')` here busts the
* home page, every listing route, and every article route at once.
*
* Secured by the `REVALIDATE_SECRET` env var (set it in Vercel for prod). Call:
* curl -X POST "https://<prod-domain>/api/revalidate" \
* -H "x-revalidate-secret: $REVALIDATE_SECRET"
*/
const SECRET_HEADER = 'x-revalidate-secret'
const isValidSecret = (provided: string | null, expected: string): boolean => {
if (!provided) return false
const providedBuffer = Buffer.from(provided)
const expectedBuffer = Buffer.from(expected)
// `timingSafeEqual` requires equal-length buffers; unequal lengths never match.
if (providedBuffer.length !== expectedBuffer.length) return false
return timingSafeEqual(providedBuffer, expectedBuffer)
}
export async function POST(request: Request): Promise<NextResponse> {
const expected = process.env.REVALIDATE_SECRET
if (!expected) {
return NextResponse.json(
{ revalidated: false, error: 'REVALIDATE_SECRET is not configured' },
{ status: 500 },
)
}
const provided =
request.headers.get(SECRET_HEADER) ??
request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ??
null
if (!isValidSecret(provided, expected)) {
return NextResponse.json({ revalidated: false, error: 'Unauthorized' }, { status: 401 })
}
revalidatePostsListing()
return NextResponse.json({ revalidated: true, tag: 'posts', now: Date.now() })
}