Several workspace modules (industries, use-cases, videos) follow a single, shared
pattern: a private WordPress Custom Post Type stores the data, the workspace app
exposes a CRUD admin UI, and the public website reads the same REST endpoint to render
the marketing surface.
This page is the canonical blueprint. If you're adding a new module that looks like "editorial content with its own page on the marketing site," follow this exactly.
| Property | Reason |
|---|---|
Private CPT (public: false) | Data lives in WP but doesn't generate WP frontend routes. Next.js owns presentation. |
REST exposed (show_in_rest: true) | The workspace and public site both fetch via /wp-json/wp/v2/<slug>. |
Show UI off (show_ui: false) | Marketing/sales users in WP admin don't see the CPT — only the workspace can edit it. |
Content stored as JSON in content.raw | Lets us version a complex page schema (hero, sections, related links, etc.) without dozens of ACF fields. |
Excerpt holds the order marker ([ORDER:NNN]) | Drag-to-reorder UX without writing to menu_order (which some WP plugins overwrite). |
Shared service layer (cpt.service.ts) | One audited module owns all WP REST plumbing, Zod validation, logging and typed errors; every CPT route is thin and identical in shape. |
The pattern intentionally does not use ACF for the main content — see ACF guidance for the line between this pattern and ACF.
┌──────────────────────────────────────────────────────────────────────┐
│ WordPress (mattersai.wpcomstaging.com) │
│ Private CPT registered via Simple CPT — Show UI: No, REST: Yes │
│ Per-entry: title, slug, status, excerpt ([ORDER:NNN]), │
│ content.raw (JSON blob: hero, sections, …) │
└──────────────────────────────────────────────────────────────────────┘
▲ ▲
│ REST (auth: Basic) │ REST (auth: Basic)
│ │
┌───────────────────────────────┴───┐ ┌───────────────┴──────────────────┐
│ Workspace (workspace.matters.ai) │ │ Public site (matters.ai) │
│ │ │ │
│ /workspace/<module>/ │ │ /<module>/ │
│ page.tsx │ │ page.tsx │
│ client-page.tsx (list + reorder)│ │ Component renders cards/grid │
│ [slug]/page.tsx │ │ │
│ [slug]/client-page.tsx (edit) │ │ /api/<module>/list/route.ts │
│ │ │ Paginated public list │
│ /api/workspace/<module>/ │ │ │
│ route.ts (GET, POST) │ │ /<module-singular>/[slug]/ │
│ [id]/route.ts (GET, PUT, DEL) │ │ page.tsx (detail page) │
│ reorder/route.ts (drag) │ │ │
└───────────────────────────────────┘ └──────────────────────────────────┘
| Module | WP CPT slug | Workspace UI | Public route | Public list API |
|---|---|---|---|---|
| Industries | industry | apps/workspace/app/(workspace)/industries/ | /industry/[slug] (Next.js) | app/api/industries/list/route.ts |
| Use Cases | use_case | apps/workspace/app/(workspace)/use-cases/ | /use-case/[slug] | app/api/use-cases/list/route.ts |
| Videos | video | apps/workspace/app/(workspace)/videos/ | /videos (gallery, deep-link modal) | app/api/videos/list/route.ts |
| Key Features | key_feature | apps/workspace/app/(workspace)/key-features/ | — (public surface TBD) | — (cache tag key-features; config KEY_FEATURE_CPT in lib/services/cpt.service.ts) |
| Platform | platform | apps/workspace/app/(workspace)/platform/ | — (public surface TBD) | — (cache tag platform; config PLATFORM_CPT in lib/services/cpt.service.ts) |
See the per-module deep dive for Videos — that's the most recent and cleanest implementation; copy it for a new module.
For a new module <module> (kebab-case for the URL; snake_case for the WP slug):
Register the CPT in WP admin via the Simple CPT plugin (already installed) with these exact settings:
| Field | Value |
|---|---|
| CPT Name | <wp_slug> (e.g. video, industry, use_case) |
| Label | Plural display name (e.g. Videos) |
| Singular Name | Singular display name |
| Public | No |
| Has Archive | No |
| Exclude From Search | Yes |
| Show UI | No ⭐ |
| Show in Menu | No ⭐ |
| Publicly Queryable | No |
| Show in REST | Yes ⭐⭐⭐ |
| Supports | Title, Editor, Excerpt, Revisions, Featured Image, Author, Page Attributes |
| Core Taxonomies | None (categories/tags live inside the content JSON) |
The three ⭐ marked rows are critical: Show UI: No is what keeps marketing/sales out of
the WP admin for this CPT. Show in REST: Yes is what lets the workspace and public site
read/write via REST.
lib/services/cpt.service.ts#All the WordPress REST plumbing (fetch, auth headers, [ORDER:NNN] parse/sort, slug→id
resolution, JSON (de)serialization, revalidateTag, error mapping) lives in one audited
module: lib/services/cpt.service.ts. The route
files are now thin — they do RBAC + validation, then call the service.
A CPT is described by a CptConfig, and each module exports one:
export interface CptConfig {
postType: string; // WP REST slug, e.g. "industry"
tag: string; // revalidateTag key, e.g. "industries"
defaultExcerpt: string; // fallback excerpt
// Per-CPT excerpt rule. When set, excerpt = excerptFrom(content) || defaultExcerpt
// (the shared hero-eyebrow heuristic is skipped). CPTs store this differently:
// industries → content.hero[0].eyebrow use cases → content.hero.promptText
// videos → content.category
excerptFrom?: (content: unknown) => string | undefined | null;
}
// Canonical configs (also: KEY_FEATURE_CPT, PLATFORM_CPT)
export const INDUSTRY_CPT: CptConfig = { postType: "industry", tag: "industries", defaultExcerpt: "…", excerptFrom: … };
export const USE_CASE_CPT: CptConfig = { postType: "use_case", tag: "use-cases", defaultExcerpt: "…", excerptFrom: … };
export const VIDEO_CPT: CptConfig = { postType: "video", tag: "videos", defaultExcerpt: "…", excerptFrom: … };
Service functions (all throw NotFoundError / ExternalServiceError from
@/lib/errors/exceptions, log via logger, and revalidateTag on writes):
| Function | Used by |
|---|---|
listCptItems(cfg) | GET route.ts — returns { id, title, slug, status, lastModified, menuOrder }[], sorted by [ORDER:NNN] |
getCptItem(cfg, idOrSlug) | GET [id]/route.ts — returns { id, title, slug, status, content } (content JSON-parsed) |
createCptItem(cfg, input) | POST route.ts |
updateCptItem(cfg, idOrSlug, partialInput) | PUT [id]/route.ts |
deleteCptItem(cfg, idOrSlug) | DELETE [id]/route.ts (trash, force=false) |
reorderCptItems(cfg, orders) | POST reorder/route.ts — stamps each excerpt with [ORDER:NNN] |
Input is validated with the exported Zod schemas: cptUpsertSchema (create), its
.partial() (update), and cptReorderSchema ({ orders: [{ id, menuOrder }] }).
Three thin route files under apps/workspace/app/api/workspace/<module>/, all on the
standard wrapper — createApiHandler (uniform error
handling, logging, CORS) + validateInput (Zod) + checkRole (RBAC) + the service:
route.ts — GET (list), POST (create)import { NextRequest, NextResponse } from "next/server";
import { createApiHandler } from "@/lib/api/wrapper";
import { checkRole } from "@/lib/auth/rbac";
import { ForbiddenError } from "@/lib/errors/exceptions";
import { VIDEO_CPT, createCptItem, cptUpsertSchema, listCptItems } from "@/lib/services/cpt.service";
import { validateInput } from "@/lib/utils/validation";
export const GET = createApiHandler(
async () => {
const videos = await listCptItems(VIDEO_CPT);
return NextResponse.json({ success: true, videos });
},
{ rateLimit: { enabled: false } }, // admin dashboard refetches freely
);
export const POST = createApiHandler(async (req: NextRequest) => {
if (!(await checkRole("org:content:edit")))
throw new ForbiddenError("You do not have permission to create videos.");
const input = validateInput(cptUpsertSchema, await req.json());
const video = await createCptItem(VIDEO_CPT, input);
return NextResponse.json({ success: true, video });
});
[id]/route.ts — GET (single), PUT (update), DELETEtype RouteCtx = { params: Promise<{ id: string }> };
export const GET = createApiHandler(async (_req, ctx: RouteCtx) => {
const { id } = await ctx.params;
return NextResponse.json({ success: true, video: await getCptItem(VIDEO_CPT, id) }); // throws NotFoundError → 404
});
export const PUT = createApiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { id } = await ctx.params;
if (!(await checkRole("org:content:edit"))) throw new ForbiddenError("…");
const input = validateInput(cptUpsertSchema.partial(), await req.json());
return NextResponse.json({ success: true, video: await updateCptItem(VIDEO_CPT, id, input) });
});
export const DELETE = createApiHandler(async (_req, ctx: RouteCtx) => {
const { id } = await ctx.params;
const canDelete = (await checkRole("org:content:delete")) || (await checkRole("org:content:edit"));
if (!canDelete) throw new ForbiddenError("…");
await deleteCptItem(VIDEO_CPT, id);
return NextResponse.json({ success: true });
});
reorder/route.ts — POST (drag-to-reorder)export const POST = createApiHandler(async (req: NextRequest) => {
if (!(await checkRole("org:content:edit"))) throw new ForbiddenError("…");
const { orders } = validateInput(cptReorderSchema, await req.json());
await reorderCptItems(VIDEO_CPT, orders);
return NextResponse.json({ success: true });
});
Response keys are per-module. The list/single keys mirror the module noun —
industries/industry,useCases/useCase,videos/video. Keep them; the client pages readdata.<key>then refetch.
Reference implementations (industries, use-cases, videos are all on this exact shape — copy any of them):
Four files under apps/workspace/app/(workspace)/<module>/:
| File | Purpose |
|---|---|
page.tsx | Server component. Wraps in requirePermission("org:content:view"), renders the client list. |
client-page.tsx | List + drag-to-reorder + search + delete + duplicate. ~430 lines following the framer-motion Reorder.Group pattern. |
[slug]/page.tsx | Server component. Wraps in requirePermission("org:content:edit"), renders the editor client. |
[slug]/client-page.tsx | The edit form. Slug new triggers create mode. Save POSTs to /api/workspace/<module> (new) or PUTs to /api/workspace/<module>/<slug> (edit). |
The list page expects an API shape of:
{ success: true, <module>: [{ id, title, slug, status, lastModified, menuOrder }] }
The edit page expects:
{ success: true, <module-singular>: { id, title, slug, status, content: {/* JSON */} } }
Pick the right surface for the module:
| Surface | Use when | Pattern |
|---|---|---|
| Single gallery page | All entries shown together (videos, testimonials grid) | Server component fetches list once at build/revalidate, renders cards |
| Listing + detail | Each entry has its own permalink (industries, use cases) | /<module> lists; /<module-singular>/[slug] renders detail |
For either, add a shared API route at app/api/<module>/list/route.ts that the public site
calls (server-side, with next.revalidate). This same route can be hit by external systems
later if needed.
| Action | Permission |
|---|---|
| View list / detail | org:content:view |
| Create / edit | org:content:edit |
| Delete (trash) | org:content:delete (falls back to org:content:edit) |
Defined in lib/auth/rbac-shared.ts. Already wired for the existing three modules.
Add an entry to the coreModules array in
apps/workspace/app/(workspace)/page.tsx
so the module shows up on the Command Center.
canViewContent && {
href: "/<module>",
title: "<Module Name>",
description: "<one-liner>",
icon: <LucideIcon>,
gradient: "from-sky-500 to-blue-600", // pick fresh colours
glowColor: "shadow-sky-500/25",
ringColor: "ring-sky-500/30",
badge: "Content",
},
curl -u "$USER:$APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/<wp_slug>?per_page=1&status=any&context=edit"
[] (empty array, HTTP 200).CptConfig for the CPT in lib/services/cpt.service.ts
(postType, tag, defaultExcerpt, plus an excerptFrom if its content shape differs
from the hero-eyebrow default), then copy the three thin route files from
apps/workspace/app/api/workspace/videos/* and swap VIDEO_CPT for your config. There's
no WP plumbing to rewrite — the service owns it.apps/workspace/app/(workspace)/videos/* into
apps/workspace/app/(workspace)/<module>/. Rename videos → <module> and adjust
field labels.[slug]/client-page.tsx — keep title/slug/status as-is,
replace the data sections with your fields. Keep the save payload as { title, slug, status, content: {...} }.app/api/<module>/list/route.ts. Reuse the
server-side helper pattern from
lib/data/videos-server.ts (with optional
static fallback if you have a hardcoded backup).app/<module>/page.tsx (and/or
app/<module-singular>/[slug]/page.tsx).apps/workspace/app/(workspace)/page.tsx.components/nav/Navbar.tsx).app/(sitemaps)/.apps/webdocs/app/website/<module>/ (or
workspace/<module>/ if it's purely internal).menu_order — Simple CPT and some WP setups override it. We standardise on
the excerpt-marker pattern ([ORDER:NNN]) which is durable.content.raw. ACF is for cross-cutting per-entity metadata, not
for module-specific schemas.revalidateTag on writes. The service does this for you in
create/update/delete/reorder — if you ever bypass the service, you must call it
yourself or the public site's cache won't notice the change for up to an hour.CptConfig.excerptFrom, not scattered in routes — otherwise the create/update/reorder
paths drift (this is exactly what happened before the service: use-cases read
hero.promptText, videos read category, industries read hero[0].eyebrow).env. getWPHeaders()
(lib/api/wp-headers.ts) reads env.WORDPRESS_USERNAME
/ env.WORDPRESS_APP_PASSWORD (declared in the env schema) — never inline
process.env or hand-roll the Basic-auth header in a route.