glokki
View .md

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, a Map in 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 softTtl an 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 Client keeps a LISTEN open, so when one instance invalidates, others clear their in-memory L1 too.
  • A Pool hands out a random connection per query, so LISTEN never receives notifications — no fan-out.

On Vercel (serverless), fan-out is impossible regardless — for two reasons:

  1. Functions are ephemeral; nothing holds a LISTEN connection open between invocations.
  2. Serverless Postgres providers (Vercel Postgres/Neon, Supabase) front the DB with PgBouncer in transaction mode, which does not support LISTEN/NOTIFY at 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 Client instead of a Pool and keep l1 on. Fan-out via LISTEN/NOTIFY then keeps every pod's L1 consistent. The channel option (default cms_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)

FieldValue
Revalidate URLhttps://yourdomain.com/api/cms-invalidate
Revalidate Secreta 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 as tenantSlug in 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 week6 * 60 * 60_000 (6h)
weekly or less24 * 60 * 60_000 (24h)
effectively static24h–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:

TagClears
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

VarUsed byNotes
POSTGRES_URLlib/cms.ts, GC cronpooled connection string on Vercel
CMS_DELIVERY_URLlib/cms.tsDelivery API base, e.g. …/api/v1/delivery
CMS_API_KEYlib/cms.tstenant Delivery API key
CMS_TENANT_SLUGlib/cms.tsnamespaces cache keys
CMS_REVALIDATE_SECRETwebhook routematches Revalidate Secret in CMS panel
CRON_SECRETGC cronVercel Cron bearer token

10. Setup checklist

  1. bun add @glokki-cms/cache @glokki-cms/cache-postgres @glokki-cms/sdk pg
  2. npx glokki-cache-pg migrate --database-url "$POSTGRES_URL" (once)
  3. Add lib/cms.ts (§4) and app/api/cms-invalidate/route.ts (§5).
  4. Set env vars (§9) in Vercel.
  5. In the CMS Visual Editor settings, set Revalidate URL → https://yourdomain.com/api/cms-invalidate and Revalidate Secret → the same value as CMS_REVALIDATE_SECRET.
  6. Add the GC cron (§8).
  7. Publish a page and confirm the next request serves the new content.