The public website renders most marketing content from a headless WordPress install
(content.matters.ai in prod). Next.js fetches pages, posts, and media via the REST API,
caches the response, and listens for a webhook from WordPress to bust the cache on edit.
Author edits page in WordPress
│
▼
WordPress fires webhook → matters.ai/api/revalidate
│ (x-webhook-secret header, validated with timingSafeEqual)
▼
Next.js calls revalidateContent({ type, id, slug })
│ → revalidateTag("wordpress", "<type>", "<type>-<id>", "<type>-<slug>")
▼
Subsequent fetch() with `{ next: { tags: [...] } }` reads fresh data
| Path | Purpose |
|---|---|
app/api/revalidate/route.ts | The WordPress webhook handler. Validates the shared secret using a constant-time compare (secureEqual), parses the Zod payload, and dispatches to revalidateContent. |
lib/cache/revalidation.ts | revalidateContent({ type, id, slug }) — maps the payload to the set of cache tags and revalidates them all together via Promise.all. |
lib/wordpress.ts + lib/api/wordpress.ts | Fetch helpers: posts, pages, categories, authors. All call fetch() with appropriate next.tags. |
lib/downloads-config.ts | Reads site-downloads-config WordPress page, shared between public-site cache and the workspace settings UI. |
{
"contentType": "post", // post | page | category | tag | author | user | media
"contentId": 1234, // optional — WordPress numeric ID
"slug": "great-blog-post" // optional — used for finer-grained tag invalidation
}
contentType is enum-bound via Zod; the other two are optional. The handler does not try
tags in a fallback order — it revalidates all applicable tags together via Promise.all.
For a post with contentId: 1234 and slug: "great-blog-post" it revalidates:
"wordpress", "post", "post-1234", and "post-great-blog-post". (No wp: prefix — the
tags are plain "<type>", "<type>-<id>", "<type>-<slug>".)
x-webhook-secret header must equal env.WORDPRESS_WEBHOOK_SECRET. The comparison uses
crypto.timingSafeEqual to defeat timing-side-channel attacks. The secret must be ≥32 chars
(enforced by the Zod schema in packages/lib/src/config/env.ts).
WORDPRESS_WEBHOOK_SECRET mismatch between WordPress plugin config and Vercel env var.POST /api/revalidate from a trusted IP to recover.WORDPRESS_CACHE_DURATION, configurable).Settings pages in WordPress (e.g. site-downloads-config) are edited from the workspace at
workspace.matters.ai/settings/*. The workspace API:
WORDPRESS_USERNAME + WORDPRESS_APP_PASSWORD.invalidateDownloadsConfigCache() on lib/downloads-cache.ts to bust the in-memory
cache so the next public-site request reads fresh data.This is the only place the workspace writes back to WordPress today. All other CRUD operations target MongoDB directly.
Customers,
Solutions → By Use Cases, Solutions → By Industries) hydrate from this same WordPress
pipeline. That page covers the route handlers, the excerpt directives marketing can use
([NAV_TITLE:...], [NAV_HIDE], [ORDER:n]), and the engineering-side slug→label override
file.