Tenant-side caching with PostgreSQL
How a tenant app caches Delivery API reads in its own PostgreSQL database, with stale-while-revalidate and webhook-driven invalidation on publish.
How a tenant app caches Delivery API reads in its own PostgreSQL database, with stale-while-revalidate and webhook-driven invalidation on publish. Written for a Next.js app deployed on Vercel (serverless), but the concepts apply to any host.
Everything here ships in two published packages (
@glokki-cms/cache,@glokki-cms/cache-postgres) plus the invalidation webhook the CMS sends on publish. Nothing lives in your repo until you wire it up.
1. What you get
createCachedClient() is a drop-in replacement for the SDK's createClient().
It wraps every Delivery API read in a two-layer cache:
request → L1 (in-process memory) → L2 (your Postgres) → Origin (Delivery API)
- L1 —
MemoryStore, aMapin RAM. Fast, but private to one process instance. Optional (and disabled on serverless — see §4). - L2 — your PostgreSQL table
cms_cache. Slower than RAM, but shared across every instance of your app. - Stale-while-revalidate — after
softTtlan entry is still served (as "stale") while a single background revalidation runs. Entries are never deleted by TTL — only by invalidation or GC. - Stale-on-error — if the origin fails during revalidation, the old entry stays. Your site keeps serving the last good content.
- Tag-based invalidation — every cached entry records surrogate tags
(
page-<id>,delivery-pages:<tenant>, …). Invalidation = "delete every entry carrying any of these tags".
2. Install
bun add @glokki-cms/cache @glokki-cms/cache-postgres @glokki-cms/sdk pg
@glokki-cms/sdk is a peer dep (^0.12.0); pg is a peer dep of the Postgres
adapter (^8.10.0).
3. Migrate the cache table (run once)
npx glokki-cache-pg migrate --database-url "$POSTGRES_URL"
This creates one table and one GIN index (idempotent — IF NOT EXISTS):
CREATE TABLE IF NOT EXISTS cms_cache (
key text PRIMARY KEY,
value jsonb NOT NULL,
fetched_at bigint NOT NULL,
soft_expire_at bigint NOT NULL,
tags text[] NOT NULL,
etag text
);
CREATE INDEX IF NOT EXISTS cms_cache_tags_gin ON cms_cache USING GIN (tags);
The GIN index on tags is what makes invalidateByTag (a tags && $1 array
overlap) fast.
4. Wire up the client
Pool vs Client — the one decision that matters
The adapter (createPostgresStore) takes a pg.Pool or a pg.Client. The
difference only affects cross-process L1 fan-out via Postgres
LISTEN/NOTIFY:
- A long-lived dedicated
Clientkeeps aLISTENopen, so when one instance invalidates, others clear their in-memory L1 too. - A
Poolhands out a random connection per query, soLISTENnever receives notifications — no fan-out.
On Vercel (serverless), fan-out is impossible regardless — for two reasons:
- Functions are ephemeral; nothing holds a
LISTENconnection open between invocations. - Serverless Postgres providers (Vercel Postgres/Neon, Supabase) front the DB
with PgBouncer in transaction mode, which does not support
LISTEN/NOTIFYat all.
So the correct serverless setup is: use a Pool and disable L1
(l1: false). L2 (shared Postgres) is then the single source of truth, and a
publish webhook clears it for every instance at once — zero inconsistency
window.
// lib/cms.ts
import { Pool } from "pg"
import { createCachedClient } from "@glokki-cms/cache"
import { createPostgresStore } from "@glokki-cms/cache-postgres"
// Module scope → reused by warm Vercel instances. Use the POOLED connection
// string (Neon/Vercel Postgres "-pooler" endpoint) and a small max so cold
// starts don't exhaust the DB connection limit.
const pool = new Pool({ connectionString: process.env.POSTGRES_URL, max: 1 })
export const cms = createCachedClient({
// ── SDK options ──
baseUrl: process.env.CMS_DELIVERY_URL!, // e.g. https://app.yourcms.com/api/v1/delivery
apiKey: process.env.CMS_API_KEY!,
tenantSlug: process.env.CMS_TENANT_SLUG!, // REQUIRED — namespaces cache keys per tenant
// ── cache options ──
cache: {
store: createPostgresStore({ client: pool }), // L2
l1: false, // serverless: no in-memory L1 → no cross-instance staleness
softTtl: 24 * 60 * 60_000, // 24h — see §6; freshness is enforced by the webhook, not TTL
revalidateInBackground: true,
// onCacheEvent: (e) => { if (e.type === "origin-error") console.warn(e) },
},
})
From here, use cms.pages.getBySlug(...), cms.collections.listEntries(...),
etc. exactly like the plain SDK — caching is transparent. Preview mode
(preview: true + previewToken) automatically bypasses the cache for
pages.getBySlug.
Non-serverless deployments (a single VM, or several long-lived pods where you want a fast shared-consistent L1): pass a dedicated
Clientinstead of aPooland keepl1on. Fan-out viaLISTEN/NOTIFYthen keeps every pod's L1 consistent. Thechanneloption (defaultcms_cache_invalidate) lets several apps share one database without crosstalk.
5. Receive invalidation webhooks (clear cache on publish)
You do not clear the cache manually after publishing — the CMS does it for you via a signed webhook. The package ships the receiver.
// app/api/cms-invalidate/route.ts
import { createInvalidationHandler } from "@glokki-cms/cache"
import { cms } from "@/lib/cms"
const handler = createInvalidationHandler({
secret: process.env.CMS_REVALIDATE_SECRET!, // == Revalidate Secret in the CMS panel
cache: cms.cache,
})
export const POST = (req: Request) => handler(req)
The handler verifies the HMAC-SHA256 signature (${timestamp}.${body} with the
shared secret over the X-Editor-Signature / X-Editor-Timestamp headers),
rejects replays outside a 5-minute window, then calls cms.cache.invalidate(tags)
(clears L2 + L1) and broadcasts publish() for fan-out. It returns 204.
The same URL also receives revalidation and data-model-purge payloads; the
handler discriminates by payload.type and politely returns 200 {ignored} for
the ones it doesn't handle, so the CMS dispatcher won't retry. A ping payload
returns 200 {ok}.
To react to those other payloads yourself (they are signature-verified by the
time you see them), pass onUnhandled — return a Response to answer the
webhook, or undefined to keep the default 200 {ignored}:
const handler = createInvalidationHandler({
secret: process.env.CMS_REVALIDATE_SECRET!,
cache: cms.cache,
// Slug-based revalidation payloads → purge the Next.js route cache too.
onUnhandled: (payload) => {
const slug = (payload as { slug?: unknown }).slug
if (typeof slug !== "string") return undefined
revalidatePath("/" + slug.replace(/^\/+/, ""))
return Response.json({ revalidated: true })
},
})
Configure the CMS (tenant Visual Editor settings)
| Field | Value |
|---|---|
| Revalidate URL | https://yourdomain.com/api/cms-invalidate |
| Revalidate Secret | a random secret (openssl rand -hex 32); same value as CMS_REVALIDATE_SECRET |
Without both set, the CMS does not dispatch invalidation webhooks (it no-ops).
6. How invalidation works on publish — and why TTL can be long
When a page's live visibility changes, the CMS dispatches a signed
{ type: "invalidate", tags: [...] } webhook:
Publish page in CMS
└─ CMS collects the affected surrogate tags
tags = [
page-<pageId>, ← the page itself
delivery-pages:<tenant>, ← page lists + getBySlug
delivery-navigation:<tenant>, ← navigation
layout:<key>:<locale> ← if the page has a layout
]
└─ POST → your Revalidate URL (signed)
└─ createInvalidationHandler → cms.cache.invalidate(tags)
└─ DELETE FROM cms_cache WHERE tags && $1 (L2, shared)
└─ L1.invalidateByTag(tags) (local, if L1 on)
The deleted entries are gone, so the next request fetches fresh content from
the origin. This is immediate and independent of softTtl.
Tenant-wide tags always use your tenant slug (
delivery-pages:<slug>), the same identifier you pass astenantSlugin the client config.
Therefore softTtl does not gate freshness. It only controls the
background-revalidation cadence for entries nobody invalidated. With
webhook-driven invalidation and rarely-changing content, a short TTL just
generates pointless origin traffic. A long TTL caches content essentially
until the next publish, with minimal load on the Delivery API.
softTtl is really your safety net for a missed webhook (network blip,
misconfig, secret mismatch): after it elapses, the entry is served stale once and
revalidated in the background, catching the change. Pick it as "how long I can
tolerate stale content if a webhook is ever dropped":
| Content changes… | Suggested softTtl |
|---|---|
| a few times a week | 6 * 60 * 60_000 (6h) |
| weekly or less | 24 * 60 * 60_000 (24h) |
| effectively static | 24h–72h |
7. Manual invalidation (when you need it)
await cms.cache.invalidate([`page-${pageId}`]) // one page
await cms.cache.invalidate([`delivery-pages:${tenant}`]) // all page lists for a tenant
const { l1Size, l2Size } = await cms.cache.stats()
cms.cache.purge()throws in v1 — there is no "clear everything". Invalidate by tag instead.
Available surrogate tags:
| Tag | Clears |
|---|---|
page-<pageId> | a single page |
delivery-pages:<tenant> | page lists + every page by slug |
delivery-navigation:<tenant> | navigation |
delivery-redirects:<tenant> | redirects |
delivery-collections:<tenant> | collection lists |
delivery-collection:<slug> | a collection + its entries |
delivery-collection-entries:<slug> | a collection's entries |
layout:<key>:<locale> | pages embedding that layout |
form:<formId> | a form |
8. Garbage-collect expired entries
Invalidation removes active entries, but entries that are never read again just
age in place (this is intentional — it's what enables stale-on-error). Add a
periodic GC so the table doesn't grow unbounded. Keep the GC window well above
softTtl so it only removes genuinely abandoned entries (any visited entry has
its fetched_at refreshed on background revalidation):
-- Delete entries not refreshed in 30 days (30d >> 24h softTtl).
DELETE FROM cms_cache
WHERE fetched_at < (EXTRACT(EPOCH FROM now()) * 1000)::bigint - 30 * 86400000;
On Vercel, run it from a Cron route:
// app/api/cron/cache-gc/route.ts (add to vercel.json crons, e.g. daily)
import { Pool } from "pg"
const pool = new Pool({ connectionString: process.env.POSTGRES_URL, max: 1 })
export async function GET(req: Request) {
// Vercel Cron sends `Authorization: Bearer ${CRON_SECRET}`.
if (
req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`
) {
return new Response("Unauthorized", { status: 401 })
}
const cutoff = Date.now() - 30 * 86400000
const { rowCount } = await pool.query(
`DELETE FROM cms_cache WHERE fetched_at < $1`,
[cutoff]
)
return Response.json({ deleted: rowCount })
}
// vercel.json
{ "crons": [{ "path": "/api/cron/cache-gc", "schedule": "0 4 * * *" }] }
9. Environment variables
| Var | Used by | Notes |
|---|---|---|
POSTGRES_URL | lib/cms.ts, GC cron | pooled connection string on Vercel |
CMS_DELIVERY_URL | lib/cms.ts | Delivery API base, e.g. …/api/v1/delivery |
CMS_API_KEY | lib/cms.ts | tenant Delivery API key |
CMS_TENANT_SLUG | lib/cms.ts | namespaces cache keys |
CMS_REVALIDATE_SECRET | webhook route | matches Revalidate Secret in CMS panel |
CRON_SECRET | GC cron | Vercel Cron bearer token |
10. Setup checklist
bun add @glokki-cms/cache @glokki-cms/cache-postgres @glokki-cms/sdk pgnpx glokki-cache-pg migrate --database-url "$POSTGRES_URL"(once)- Add
lib/cms.ts(§4) andapp/api/cms-invalidate/route.ts(§5). - Set env vars (§9) in Vercel.
- In the CMS Visual Editor settings, set Revalidate URL →
https://yourdomain.com/api/cms-invalidateand Revalidate Secret → the same value asCMS_REVALIDATE_SECRET. - Add the GC cron (§8).
- Publish a page and confirm the next request serves the new content.