---
title: "Tenant-side caching with PostgreSQL"
url: "https://glokki.com/docs/tenant-cache-postgres"
locale: "en"
updated: "2026-10-14T21:07:16.960Z"
---

# 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

```bash
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)

```bash
npx glokki-cache-pg migrate --database-url "$POSTGRES_URL"
```

This creates one table and one GIN index (idempotent — `IF NOT EXISTS`):

```sql
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.

```ts
// 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.

```ts
// 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}`:

```ts
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 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 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)

```ts
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):

```sql
-- 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:

```ts
// 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 })
}
```

```json
// 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

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.
