Owner: Admin/API track. Depends on
00-architecture-overview.md. Sister docs:01-core-cms.md(data model),04-block-editor.md(the editor that the admin hosts),06-auth-permissions.md(capabilities/roles),08-migration-compat.md(WP REST shim deep-dive).Scope: the admin SPA (information architecture, list/edit screens, settings, plugin/theme admin, users, onboarding, UI framework, routing, state) and the public-facing API surface (REST, GraphQL, auth, rate limiting, webhooks, versioning, OpenAPI, CLI).
Out of scope: the block editor internals (doc 04), the WASM plugin runtime (doc 02), the renderer/theme system (doc 03).
- Mental model and shared assumptions
- PART A — Admin dashboard
- Information architecture (sidebar tree)
- Dashboard home and widgets
- List screens
- Edit screens
- Site editor (block themes)
- Settings registry
- Plugins and themes admin
- Users admin
- Notifications and inbox
- Onboarding wizard
- UI framework recommendation
- Routing and layout
- State management
- i18n
- PART B — API surface
15. REST API
16. GraphQL API
17. WordPress REST compatibility shim
18. Auth (cookies, JWT, OAuth2, API keys)
19. Rate limiting and abuse
20. Webhooks
21. API versioning
22. OpenAPI and schema docs
23. CLI (
gonext) - Trade-offs and rejected alternatives
- Open questions
Two things ship together in this doc because they are two views of the same data:
- The admin is a consumer of the API. There is no privileged admin-only data path. If the admin can do it, an API client with the same capabilities can do it. This forces the API to be complete.
- The API is not a thin database wrapper. It enforces the capability system from doc 06, fires hooks/filters from doc 02, validates against the content-type schemas from doc 01, and emits webhook events.
Concretely:
Admin SPA ─────► REST/GraphQL ─────► Capability check ─────► Hook bus ─────► Repo ─────► Postgres
│
Public Next.js render ─► same API ───────┘ (read-only token, same capability gate)
CLI / 3rd-party ─────► same API ─────────┘ (JWT or API key, same capability gate)
A few invariants we will keep reaching for:
- Everything is a resource with a stable
{id, type, slug?}triple. Plugin CPTs are first-class. - All admin pages are deep-linkable.
/admin/posts?status=draft&author=42&page=3survives reloads. - No silent admin-only endpoints. If we add an endpoint, it appears in OpenAPI and in the GraphQL schema unless explicitly internal (and internal endpoints are namespaced
/api/_internal/and require an admin cookie).
WP admin's IA is one of its biggest assets and biggest liabilities: it is familiar to a million people but contains 20 years of barnacles (Tools → Available Tools is a museum piece). We keep the spine and discard the rot.
Admin
├── Dashboard [/admin]
│ └── Home (default)
│
├── Content
│ ├── Posts [/admin/posts]
│ │ ├── All posts
│ │ ├── New post
│ │ ├── Categories [/admin/taxonomies/category]
│ │ └── Tags [/admin/taxonomies/post_tag]
│ ├── Pages [/admin/pages]
│ ├── Media [/admin/media]
│ ├── Comments [/admin/comments]
│ └── <Custom post types> [/admin/types/{slug}] (plugins inject here)
│
├── Design
│ ├── Site editor [/admin/site-editor] (block themes only)
│ ├── Themes [/admin/themes]
│ ├── Customize [/admin/customize] (classic themes only)
│ └── Menus [/admin/menus]
│
├── Extend
│ ├── Plugins [/admin/plugins]
│ └── Marketplace [/admin/marketplace]
│
├── People
│ ├── Users [/admin/users]
│ ├── Roles & capabilities [/admin/roles]
│ └── Profile [/admin/profile]
│
├── Tools
│ ├── Import [/admin/tools/import]
│ ├── Export [/admin/tools/export]
│ ├── Site health [/admin/tools/health]
│ ├── Logs [/admin/tools/logs]
│ └── Scheduled tasks [/admin/tools/jobs]
│
├── Settings
│ ├── General [/admin/settings/general]
│ ├── Writing [/admin/settings/writing]
│ ├── Reading [/admin/settings/reading]
│ ├── Discussion [/admin/settings/discussion]
│ ├── Media [/admin/settings/media]
│ ├── Permalinks [/admin/settings/permalinks]
│ ├── Privacy [/admin/settings/privacy]
│ └── <Plugin settings> [/admin/settings/{slug}]
│
└── Notifications [/admin/inbox] (badge in top bar)
| WP | Us | Why |
|---|---|---|
| Appearance | Design | "Appearance" is a 2003 word; "Design" reads better next to "Site editor". |
| Plugins (top-level) | Under Extend | Groups plugins + marketplace; reduces top-level clutter. |
| Settings → Writing/Reading split | Kept | The names are bad but switching breaks every WP user's muscle memory. We keep them and rely on descriptions inside the page. |
| Tools → Available Tools (empty page) | Removed | It exists in WP only for plugins to inject into. We use the Tools area itself. |
wp-admin/options.php |
Not exposed | The "all settings" debug page is replaced by gonext option CLI. |
| Comments is a top-level menu | Demoted under Content | Most installs don't use comments; promote it only if at least one post type has comments enabled. |
| No notifications inbox | Added | Replaces the "admin notices" pattern (see §2.9). |
The admin nav is a registry sourced from each installed plugin's manifest (doc 02 owns the manifest format and the admin_pages array — see doc 02 §2.2 / §7.3). On boot, and on every plugin install/activate/deactivate, the admin shell scans the manifests of all installed plugins and assembles the sidebar tree.
Concretely, each entry in a plugin's admin_pages array contributes:
| Manifest field | Sidebar effect |
|---|---|
id |
Stable nav-item id (namespaced by plugin slug). |
parent |
Mounts under content / extend / tools / settings / design / users / null (top-level). |
label, icon |
Display. Label is i18n-resolved against the plugin's translation bundle (see §2.14). |
capability |
Capability gate. The user must hold this capability or the menu item is not rendered (server-side filter — we don't ship a ghost item to the client). |
order |
Sort within parent. |
entry |
Path to the plugin's ES module that mounts the page; loaded into a DOM-scoped slot in the admin shell (sandboxed bundle, same as any other plugin UI surface — see §4 "Per-plugin admin routes vs sandboxed iframes" and doc 02 §7.6 for the CSP/scoping rules). |
children |
Nested sub-items, same shape. |
The runtime SDK helper AdminMenu.register({...}) is build-time sugar: when the plugin is built, the SDK writes the registration into the plugin's manifest.json admin_pages array. There is no runtime register call — the shell never executes plugin code to discover what pages exist. The shell only loads a plugin's entry module when the user actually navigates to that page. (Fixed per review B6/C8 — manifest is the single source of truth; reconciles with doc 02 §2.2.)
Auth gating is double-enforced: the shell hides menu items the user lacks the capability for, and the API endpoint backing each page independently re-checks the same capability server-side (defence-in-depth — see doc 06 §7.4 and §3.4 of this doc).
We deliberately reject WP's add_menu_page callback model; everything is declarative so the nav can be SSRed and rendered without executing plugin code on every page load.
The home is the first screen after login. WP's version is a graveyard of widgets; ours is intentionally sparse with strong defaults and aggressive extension points.
| Widget | Content | Source |
|---|---|---|
| At a glance | Counts of posts / pages / comments / users; site name, theme, plugin counts. | GET /api/v1/site/at-a-glance |
| Activity | Last 10 events: published, drafts, comments awaiting moderation. | GET /api/v1/activity?limit=10 |
| Quick draft | Title + content, save as draft. | POST /api/v1/posts (status=draft) |
| Site health | A traffic-light summary linking to /admin/tools/health. |
GET /api/v1/site/health/summary |
| What's new | Release notes for new core/plugin versions you've installed. RSS-style. | GET /api/v1/updates/news |
┌──────────────────────────────────────────────────────────────────┐
│ Top bar: site switcher · search · new · notifications · profile │
├────────────┬─────────────────────────────────────────────────────┤
│ │ │
│ Sidebar │ ┌──────────────────┐ ┌──────────────────┐ │
│ │ │ At a glance │ │ Site health │ │
│ │ └──────────────────┘ └──────────────────┘ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │
│ │ │ Activity feed │ │ Quick draft │ │
│ │ │ (large) │ │ │ │
│ │ │ │ └──────────────────┘ │
│ │ │ │ ┌──────────────────┐ │
│ │ │ │ │ What's new │ │
│ │ └──────────────────┘ └──────────────────┘ │
│ │ │
└────────────┴─────────────────────────────────────────────────────┘
DashboardWidget.register({
id: 'analytics.summary',
title: t('Traffic (7d)'),
size: 'md', // sm | md | lg
capability: 'view_analytics',
// The component is a React lazy import; the loader pulls it from
// the plugin's UI bundle declared in the import map.
component: () => import('@plugins/analytics/widgets/Summary'),
defaultEnabled: true,
});Users can drag, hide, and reorder widgets; layout is persisted per-user via PUT /api/v1/users/me/preferences.
Every list screen (posts, pages, CPTs, media, comments, users) shares a single <ResourceList> shell. This is the highest-leverage component in the entire admin.
┌──────────────────────────────────────────────────────────────────┐
│ Page title · primary action (New post) │
├──────────────────────────────────────────────────────────────────┤
│ Tabs: All (123) | Mine (8) | Drafts (4) | Trash (12) │
├──────────────────────────────────────────────────────────────────┤
│ [ Search box ] [ Filter chips: status, author, date, taxonomy ] │
│ [ Saved views ▼ ] [ Bulk actions ▼ ] [ Columns ▼ ] │
├──────────────────────────────────────────────────────────────────┤
│ ☐ Title Author Categories Date Status │
│ ☐ Hello World admin Uncategorized May 10 Pub │
│ ☐ Draft notes editor — May 09 Draft │
│ ... │
├──────────────────────────────────────────────────────────────────┤
│ ‹ prev 1 2 3 ... 10 next › 25 ▼ per page │
└──────────────────────────────────────────────────────────────────┘
| Feature | Behaviour |
|---|---|
| Search | Server-side; defaults to title + excerpt; advanced syntax author:42 status:draft parsed into filters. |
| Filters | Pill chips; URL-synced (?status=draft&author=42). |
| Saved views | A filter+sort+columns combination, stored per-user; team-shared views possible (scope: 'team'). Backed by view_preferences table. |
| Bulk actions | Trash, restore, change status, change author, change terms; plugins register more. |
| Sortable columns | Click header. Multi-sort via shift-click. URL: ?sort=-date,title. |
| Sticky toolbar | Survives scroll; bulk-action bar appears when rows are selected. |
| Row density | Comfortable / compact toggle, per-user. |
| Empty / loading / error states | First-class — every list screen ships all three. |
| Real-time | Server-Sent Events stream resource.* events; new posts appear with a "1 new — refresh" pill instead of jumping. |
ResourceColumn.register({
resource: 'post',
id: 'seo.score',
label: t('SEO'),
width: 80,
capability: 'view_seo',
// Cell receives the resource row.
cell: ({ row }) => <SeoBadge score={row.meta['seo.score']} />,
// Optional: makes the column sortable and tells the API how to sort it.
sort: { fields: ['meta.seo.score'] },
});The API supports column field selection so a plugin column can opt into fetching only its own meta key (see §3.1 sparse fieldsets).
BulkAction.register({
resource: 'post',
id: 'seo.rescan',
label: t('Rescan SEO'),
capability: 'edit_posts',
confirmation: ({ count }) => t('Rescan {count} posts?', { count }),
run: async (ids) => {
// Returns a job id; UI shows progress in the inbox.
return api.post('/plugins/seo/jobs/rescan', { ids });
},
});Bulk operations that affect >25 rows are always backgrounded via Asynq (see doc 00) and surface in the inbox as a progress notification.
Four edit screen shapes:
- Single-post editor — hosts the block editor (doc 04).
- Term editor — for taxonomy terms (category, tag, custom).
- User profile — same shell as Settings, scoped to a user.
- Media detail — preview + metadata + usages.
The editor itself is doc 04's territory; what this doc covers is the frame around it: the chrome the admin provides, the sidebar panels, the publish flow.
┌──────────────────────────────────────────────────────────────────┐
│ ← back Document title (auto) Preview · Save · Publish │
├──────────────────────────────────────────────┬───────────────────┤
│ │ Sidebar panels: │
│ │ - Status │
│ │ - Permalink │
│ │ - Categories │
│ <Block editor — doc 04> │ - Tags │
│ │ - Featured image │
│ │ - Excerpt │
│ │ - Discussion │
│ │ - Revisions │
│ │ - <plugin panel> │
│ │ - <plugin panel> │
│ │ │
├──────────────────────────────────────────────┴───────────────────┤
│ Footer: word count · last saved · validation summary │
└──────────────────────────────────────────────────────────────────┘
EditorPanel.register({
id: 'seo.panel',
resource: 'post' | ['post', 'page'], // applies to multiple types
title: t('SEO'),
icon: SearchIcon,
capability: 'edit_post',
defaultCollapsed: false,
order: 60,
component: () => import('@plugins/seo/panels/Editor'),
});The panel component receives a usePostEditing() hook giving live access to the post draft (title, content, meta) and an update() function. Plugins persist their data into the post's meta JSONB column under their slug-prefixed namespace.
Publishing is a state transition, not an HTTP verb. The button in the corner is a menu:
- Save draft (default for new)
- Submit for review (if user lacks
publish_posts) - Publish (if user has
publish_posts) - Schedule… (date picker — sets
status=future, publish_at=…) - Save & duplicate
Pre-publish checklist (modal) — extensible:
PrePublishCheck.register({
id: 'seo.title-length',
resource: 'post',
severity: 'warning' | 'blocker',
check: (post) => post.meta['seo.title'].length <= 60 ? null
: t('SEO title is too long ({len} chars)', { len: ... }),
});Core ships checks for: missing excerpt, missing featured image (if theme requires), no categories, broken internal links.
A compact two-pane: existing terms on the left as a list, "Add new" form on the right (WP's pattern, kept because it works for power users). Hierarchical taxonomies show a tree.
Tabs: Profile · Account · Sessions · API keys · Roles · Preferences. The "Sessions" tab lists active sessions (browser, IP, last active) with a revoke button (see §3.4).
A slide-over drawer (not a route change) showing: large preview, alt text, caption, title, dimensions, file size, EXIF (collapsed), regenerate-thumbnails button, "Used in" — a list of posts that embed this attachment (computed by the media service, doc 07).
When the active theme declares "capabilities": { "fse": true } in its theme.json, the Design menu shows Site editor instead of Customize.
This screen is similar to the post editor but the document it edits is a template or template part, not a post. The data flow is different:
| Post editor | Site editor | |
|---|---|---|
| Document | A post row | A template or template-part row |
| Storage | posts.content_blocks (JSONB) |
templates.blocks / template_parts.blocks |
| Save | PUT /api/v1/posts/{id} |
PUT /api/v1/templates/{id} |
| Preview context | Single post view | Front-page / archive / single mock with sample data |
| Allowed blocks | All registered | All + template-specific (Query Loop, Site Title, Nav…) |
┌──────────────────────────────────────────────────────────────────┐
│ Sidebar (left, collapsible): │
│ Templates │
│ ├── index │
│ ├── single │
│ ├── single-post │
│ ├── archive │
│ ├── 404 │
│ └── + Add new │
│ Template parts │
│ ├── header │
│ ├── footer │
│ └── sidebar │
│ Patterns │
│ Styles (global theme.json overrides) │
│ Pages (editing landing pages directly) │
│ │
│ Canvas (center): live preview with editing │
│ Inspector (right): selected-block settings │
└──────────────────────────────────────────────────────────────────┘
A dedicated tab editing the user's overrides of the theme's theme.json. Saved to a global_styles row, merged with theme defaults at render. See doc 03 for the merge rules.
All settings pages share one form shell: a left rail of sections, a right column of fields, a sticky save bar. Pages load instantly because the form is rendered from a schema, not coded per page.
Every setting is declared with JSON Schema + UI hints. Core ships the WP settings; plugins/themes extend.
Settings.register({
page: 'general',
section: 'identity',
key: 'site.title',
schema: { type: 'string', maxLength: 80, default: 'My Site' },
ui: { widget: 'text', label: t('Site title'), description: t('Shown in the browser tab') },
capability: 'manage_options',
});
Settings.register({
page: 'reading',
section: 'frontpage',
key: 'reading.front_page',
schema: { type: 'string', enum: ['posts', 'page'] },
ui: { widget: 'radio', label: t('Your homepage displays') },
capability: 'manage_options',
});The Go server holds the canonical registry and exposes:
| Endpoint | Purpose |
|---|---|
GET /api/v1/settings/schema?page=general |
Returns the JSON Schema + UI hints for that page. |
GET /api/v1/settings?page=general |
Returns current values. |
PUT /api/v1/settings |
Body: { "site.title": "...", ... }. Validates against schema. |
| Page | Sections | Sample keys |
|---|---|---|
| General | Identity, Locale, Date/time | site.title, site.tagline, site.icon, site.url, general.timezone, general.date_format |
| Writing | Defaults, Posting | writing.default_category, writing.default_format, writing.editor |
| Reading | Front page, Feeds, Visibility | reading.front_page, reading.posts_per_page, reading.feed_items, reading.search_engine_visible |
| Discussion | Comments, Avatars, Moderation | discussion.allow_comments, discussion.require_login, discussion.moderation_words |
| Media | Sizes, Organization | media.thumbnail_size, media.medium_size, media.organize_by_date |
| Permalinks | Structure | permalinks.post_structure, permalinks.category_base, permalinks.tag_base |
| Privacy | Privacy page, Data retention | privacy.policy_page_id, privacy.cookie_banner |
- One renderer for the entire admin's forms (
<SchemaForm schema={...} value={...} onChange={...} />). - OpenAPI exposes the same schemas for free.
- The CLI (
gonext option get site.title) uses the same schemas to validate. - Plugins ship their settings page by registering schemas — no UI code required.
Shell similar to a list screen, but cards instead of rows (plugins have icons and screenshots).
┌──────────────────────────────────────────────────────────────────┐
│ Installed (8) | Active (5) | Updates (2) | All │
│ [ search ] [+ Add] │
├──────────────────────────────────────────────────────────────────┤
│ ┌───────┐ Plugin name v1.2.3 ● Active │
│ │ icon │ Short description │
│ └───────┘ by Author · 12,345 installs │
│ Update available → 1.3.0 [Update] [Deactivate]… │
│ … │
└──────────────────────────────────────────────────────────────────┘
+ Add opens a panel with two tabs:
- Marketplace — browses
/api/v1/marketplace/plugins(federated registry, see doc 02). Cards link to detail pages with screenshots, changelog, capability list, ratings. - Upload — drop a
.zip(or paste a registry URL). Server validates the bundle against the plugin manifest schema (doc 02).
Critical for security: WASM plugins request capabilities (DB scope, HTTP egress allowlist, KV access, hook subscriptions). Before activation we display:
┌──────────────────────────────────────────────────────────────────┐
│ Activate "SEO Pro" v1.3.0? │
│ │
│ This plugin requests the following permissions: │
│ │
│ ● Read posts │
│ ● Read & write post meta (namespace: seo.*) │
│ ● Make HTTP requests to: │
│ api.seo-pro.example │
│ www.googleapis.com (sitelinks endpoint) │
│ ● Run on hooks: post.save, post.publish │
│ ● Schedule background jobs (rate limit: 10/min) │
│ │
│ This plugin is NOT requesting: │
│ ● Access to users, comments, or media bodies │
│ ● Filesystem or shell │
│ │
│ [ Cancel ] [ Review code ▾ ] │
│ [ Activate ] │
└──────────────────────────────────────────────────────────────────┘
Capability requests come from the plugin's signed manifest. Mismatch between manifest and runtime request = activation refused.
Updates check is a cron job (Asynq). New version → notification in the inbox. Auto-update is opt-in per plugin and per minor/major.
/admin/themes is similar but presents themes as a gallery. "Activate" is a one-click switch; classic theme → Site Editor is hidden, customizer is shown. Theme delete is blocked if active or a parent of the active child theme.
Same <ResourceList> shell. Columns: avatar, name, email, role(s), last login, posts. Filters: role, status (active, suspended, invited).
Two flows:
- Create with password — admin sets a password.
- Invite by email — sends a magic-link token (one-time, 7 days). The user sets their own password.
A separate screen at /admin/roles:
Six roles ship by default — slugs (lowercase) are super_admin, administrator, editor, author, contributor, subscriber. Doc 06 §6.1 is the canonical seeded role list; doc 08 §7.3 must use the same slugs when mapping imported WP users. (Fixed per review C12 — include super_admin; standardize on administrator slug.)
┌──────────────────────────────────────────────────────────────────────┐
│ Roles: Super admin · Administrator · Editor · Author · Contrib · │
│ Subscriber + Custom role │
├──────────────────────────────────────────────────────────────────────┤
│ Capability SAdm Admin Edit Auth Contrib Subs │
│ ──────────────────────── ── ── ── ── ── ─ │
│ manage_network ✓ · · · · · │
│ manage_options ✓ ✓ · · · · │
│ manage_users ✓ ✓ · · · · │
│ manage_plugins ✓ ✓ · · · · │
│ edit_posts ✓ ✓ ✓ ✓ ✓ · │
│ publish_posts ✓ ✓ ✓ ✓ · · │
│ edit_others_posts ✓ ✓ ✓ · · · │
│ delete_others_posts ✓ ✓ ✓ · · · │
│ moderate_comments ✓ ✓ ✓ · · · │
│ upload_files ✓ ✓ ✓ ✓ · · │
│ … │
└──────────────────────────────────────────────────────────────────────┘
Cells are clickable when in edit mode. The full capability catalogue lives in doc 06; this screen is its UI.
Custom roles are stored in the roles table (id, slug, name, is_builtin, description), with capabilities normalized into a role_capabilities join table — see doc 06 §6 for the canonical schema. Plugins can register both new capabilities and grant defaults to existing roles via their manifest. (Fixed per review C13 — capabilities are a normalized join, not a JSONB column.)
Fields: display name, email, bio, locale, avatar (Gravatar fallback or upload), website, social URLs, app passwords (legacy WP compat), API keys (see §3.4).
WP's "admin notices" pattern is one of the worst things about it: plugins inject dismissable yellow boxes on every page. We replace it with a unified inbox.
type Notification struct {
ID uuid.UUID
UserID *uuid.UUID // null = broadcast to all users with capability
Capability string // filter recipients by capability (e.g. 'manage_plugins')
Kind string // 'system' | 'plugin' | 'job' | 'security'
Source string // 'core' | plugin slug
Severity string // 'info' | 'success' | 'warning' | 'error'
Title string
Body string // markdown
Actions []Action // [{label, href|action}]
CreatedAt time.Time
ReadAt *time.Time
DismissedAt *time.Time
}- Bell icon in the top bar with unread badge.
- Click → popover with the 10 most recent + "View all" link to
/admin/inbox. - Inbox page is a
<ResourceList>(filters: severity, source, read/unread).
| Source | Examples |
|---|---|
| Core | Update available, low disk space, scheduled backup failed |
| Job | "Imported 1,234 of 2,000 posts (61%)" — updates in place |
| Plugin | SEO Pro: 12 posts need attention |
| Security | New login from unrecognized device |
await Notifications.send({
capability: 'manage_seo',
severity: 'warning',
title: 'SEO scan found 12 issues',
body: '...',
actions: [{ label: 'Review', href: '/admin/seo-pro/issues' }],
});Rate-limited per plugin (default 20/hour) to prevent the WP-style notice spam.
First-run flow at /admin/setup. The server detects "no users exist" or "setup not complete" and forces this route until done.
- Welcome — language, site title, tagline.
- Admin account — email, password (or SSO connect).
- What is this site? — blog · business · store · portfolio · custom. Adjusts defaults (front-page type, sample content, suggested plugins).
- Theme — pick from 3 curated themes or skip (default theme). Inline previews.
- Sample content — checkbox: install sample posts/pages so the empty state isn't scary. Defaults to on.
- Optional plugins — based on Step 3 (e.g. "Store" → suggest commerce plugin).
- Done — confetti, link to dashboard.
WordPress's "Famous 5-minute install" is famous because every other CMS's onboarding was worse, not because it was good. The empty post list, the bewildering settings, the unconfigured permalinks — that's where most new admins bounce. We give them a working site after step 5.
The wizard is itself a Next.js route group /admin/(setup)/ and is the only place an unauthenticated session can reach an admin path.
Recommendation: shadcn/ui + Tailwind + Radix primitives, with React Aria Components as fallback for any primitive Radix doesn't cover (table, grids).
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Mantine | Batteries-included, great DX, native CSS-in-JS, dark mode, hooks. | We don't own the components (must theme around them), the table component is heavy, Mantine v7 still settling. | Strong runner-up. |
| shadcn/ui + Tailwind + Radix | We own the source of every component (copied into our repo), Radix gives accessible primitives, Tailwind is the lingua franca, easy to fork. | Slightly more manual setup; no built-in form/table — we build on TanStack. | Picked. |
| Custom + React Aria + Tailwind | Maximum control, accessibility-first. | We build everything from scratch. Too much surface area for v1. | Reject for v1; revisit for v2 if shadcn becomes a constraint. |
| Material UI | Mature. | Looks like Google; theming away from that is a fight; bundle weight. | Reject. |
| Chakra | Pleasant. | Project momentum has slowed; v3 churn. | Reject. |
The admin is the highest-leverage surface for design taste. We need to own every pixel without writing CSS for every box-shadow. shadcn's "copy the component into your repo" model means the framework can't deprecate us. Tailwind compiles to a small CSS file. Radix gives us accessibility for free.
// theme.css (generated)
:root {
--bg: 0 0% 100%;
--fg: 240 10% 4%;
--primary: 220 90% 56%;
--border: 240 6% 90%;
--radius: 0.5rem;
...
}
.dark {
--bg: 240 10% 4%;
--fg: 0 0% 98%;
--primary: 220 90% 66%;
--border: 240 4% 16%;
}Plugins ship components via the import map and reuse the same tokens. We do not ship a JS-based theming API for plugins; they style with classnames/tokens or use their own CSS, scoped via Shadow DOM in heavily-stylesheet-conflicting cases.
System default + per-user override. Toggle in the top bar. Persisted in user_preferences.
- WCAG 2.1 AA target.
- Keyboard nav for every action (the entire admin is operable without a mouse).
- Visible focus rings (never
outline: none). - Reduced-motion respected.
apps/admin/ (Next.js 14, App Router)
app/
(setup)/
setup/page.tsx onboarding
(admin)/
layout.tsx shell (sidebar, top bar, providers)
page.tsx dashboard home
posts/
page.tsx list
new/page.tsx editor (new)
[id]/page.tsx editor (existing)
pages/...
media/...
themes/...
site-editor/...
plugins/...
users/...
settings/[page]/page.tsx
tools/...
inbox/page.tsx
api/
[...proxy]/route.ts (optional) BFF that adds CSRF and forwards to Go
components/
lib/
Each top-level area is its own route segment, which Next code-splits by default. The block editor is a heavy chunk and is dynamic-imported only on /admin/posts/[id] and /admin/site-editor.
The admin shell is a single layout component holding: sidebar nav, top bar (search, notifications, profile, new), and the main content area. It is not re-rendered on navigation; only the inner segment changes.
┌──────────┬──────────────────────────────────────────────────────┐
│ │ Top bar (sticky) │
│ Sidebar ├──────────────────────────────────────────────────────┤
│ (sticky) │ │
│ │ <Outlet /> │
│ │ │
└──────────┴──────────────────────────────────────────────────────┘
The admin is a separate Next.js app from the public site. Reasons:
- Different bundle profiles. The public site optimises for first paint (RSC, minimal client JS); the admin is a heavy SPA. Forcing one bundle to satisfy both is a permanent compromise.
- Different deploy cadence. Admin can ship daily; the public render must roll forward carefully (caches, ISR).
- Different auth. Admin cookies, public read-mostly.
- Different surface area for plugins. Plugin UI extensions only load in admin; we shouldn't ship them to anonymous readers.
Counter-argument: dev experience is simpler with one app (one server, shared types). Mitigated by a monorepo (pnpm workspace) sharing packages/api-types, packages/ui (token + base components), and packages/sdk.
| Path | Notes |
|---|---|
/admin |
Dashboard home |
/admin/{section} |
Section landing (list or main) |
/admin/{section}/{id} |
Edit |
/admin/{section}/new |
Create |
/admin/{section}?q=… |
Search/filter (URL-synced) |
/admin/setup |
Onboarding (auto-redirect when needed) |
/admin/login |
Login (only path reachable unauthenticated other than setup) |
Two libraries, one rule:
Server state lives in TanStack Query. Ephemeral UI state lives in Zustand. Nothing else.
- One
<QueryClient>per admin app, mounted in the shell. - Keys:
['posts', { status, page }],['post', id],['settings', page]. - Stale-while-revalidate by default; mutations call
invalidateQueries. - Server-Sent Events (
GET /api/v1/events) push invalidations: when another tab publishes a post, our cache is invalidated.
const { data, isLoading } = useQuery({
queryKey: ['posts', { status: 'draft', page }],
queryFn: () => api.get('/posts', { params: { status: 'draft', page } }),
staleTime: 30_000,
});- Per-feature stores:
useEditorUI,useListSelection,useNavCollapsed. - Local only, never persisted unless explicitly (sidebar width, theme).
- No global "everything" store — that's a 2018 anti-pattern.
const useEditorUI = create<EditorUIState>((set) => ({
rightPanelOpen: true,
setRightPanelOpen: (v) => set({ rightPanelOpen: v }),
}));- Redux is too much ceremony for an app that mostly mirrors server state.
- Jotai is great but TanStack Query subsumes the "shared async state" use case we'd otherwise reach for atoms for.
- Zustand is the smallest thing that solves the remaining problem.
react-hook-form + zod resolver, validated against zod schemas generated from the server's JSON Schema (a small build-step in packages/api-types).
- ICU MessageFormat under the hood.
- Supports plurals, gender, nested format, dates, numbers, currencies.
- Loads message catalogues per locale; server-renders translated.
Catalogues live in apps/admin/messages/{locale}.json. Each key is namespaced (admin.posts.title, editor.publish.button).
Plugins ship a translations/ folder in their bundle:
my-plugin/
translations/
en.json
fr.json
de.json
On plugin load, the admin merges the plugin's catalogue into the runtime under a namespace (plugin.my-plugin.*). Plugin code uses:
import { useTranslations } from '@gonext/sdk/i18n';
const t = useTranslations('plugin.my-plugin');
t('settings.label');Validation errors, email templates, and CLI strings are translated on the server. Go uses golang.org/x/text/message with the same ICU-style catalogues. A build step (pnpm i18n:sync) ensures the two sides stay aligned via a shared key list.
- URL prefix
/admin/{locale}/…— explicit (preferred). - User preference (per-account setting).
Accept-Languageheader.- Site default.
Tailwind has dir="rtl" support via the rtl: variant. Admin layout uses logical properties (ms-, me-, ps-, pe-) so RTL is automatic.
- HTTP-resourceful, not RPC. Verbs are HTTP verbs. Resources have stable URLs.
- Predictable. Same conventions across every resource so SDKs can be tiny.
- Embed, don't N+1. Clients can opt into joining related resources.
- Versioned.
/api/v1/...from day one (see §3.7). - Boring before clever. We adopt JSON:API-ish conventions but don't ship full JSON:API — the spec is overkill.
The REST API exposes the core.seo.* meta keys (see doc 01 §3.3 for the canonical key list and JSON Schema) as part of every post / page / CPT resource — these include canonical title, description, canonical URL, OpenGraph fields, robots directives, schema.org overrides, and breadcrumb hints. SEO meta is core schema, not a plugin extension: every install gets the same core.seo.* field group on every public content type. The bundled gn-seo reference plugin (doc 02) enriches this surface — it adds analysis, sitemap generation, structured-data emission — but it does not own the meta keys. Removing or disabling gn-seo does not lose data, since the values live in posts.meta under the core namespace. Clients can read/write these fields with the standard sparse-fieldset projection (?fields=meta.core.seo). (Fixed per review gap A12.)
| Pattern | Meaning |
|---|---|
/api/v1/{collection} |
List + create |
/api/v1/{collection}/{id} |
Read + update + delete |
/api/v1/{collection}/{id}/{sub} |
Sub-resource list |
/api/v1/types/{type}/items |
Generic CPT access (e.g. /api/v1/types/product/items) |
/api/v1/me |
Current user shortcut |
/api/plugins/{slug}/... |
Plugin-registered endpoints (sandbox, see doc 02) |
/api/_internal/... |
Admin-only, undocumented, cookie auth required |
/wp-json/wp/v2/... |
WordPress REST compatibility shim — canonical (§3.3) |
/api/wp-json/wp/v2/... |
WP REST shim — secondary alias for internal tooling (§3.3) |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/posts |
List posts (filterable) |
POST |
/api/v1/posts |
Create post |
GET |
/api/v1/posts/{id} |
Read |
PUT |
/api/v1/posts/{id} |
Replace |
PATCH |
/api/v1/posts/{id} |
Partial update |
DELETE |
/api/v1/posts/{id} |
Move to trash; ?force=true to hard-delete |
POST |
/api/v1/posts/{id}/revisions |
Snapshot |
GET |
/api/v1/posts/{id}/revisions |
List revisions |
POST |
/api/v1/posts/{id}/restore |
Restore from trash or revision |
GET |
/api/v1/pages … |
(same shape) |
GET |
/api/v1/types/{type}/items |
CPT list |
GET |
/api/v1/taxonomies |
Registered taxonomies |
GET |
/api/v1/taxonomies/{tax}/terms |
Terms |
POST |
/api/v1/taxonomies/{tax}/terms |
Create term |
GET |
/api/v1/media |
List media |
POST |
/api/v1/media |
Upload (multipart or signed URL flow) |
GET |
/api/v1/media/{id} |
Detail |
GET |
/api/v1/comments |
List |
POST |
/api/v1/comments |
Create |
PATCH |
/api/v1/comments/{id} |
Moderate |
GET |
/api/v1/users |
List |
POST |
/api/v1/users |
Create / invite |
GET |
/api/v1/users/{id} |
Read |
GET |
/api/v1/me |
Current user |
PUT |
/api/v1/me/preferences |
UI prefs |
GET |
/api/v1/roles |
List roles |
PUT |
/api/v1/roles/{slug} |
Update role |
GET |
/api/v1/settings?page=… |
Get settings |
PUT |
/api/v1/settings |
Update |
GET |
/api/v1/settings/schema |
JSON Schema (all pages, by page) |
GET |
/api/v1/plugins |
Installed plugins |
POST |
/api/v1/plugins |
Install (multipart .zip or { source: 'marketplace', id }) |
POST |
/api/v1/plugins/{slug}/activate |
Activate (requires capability ack) |
POST |
/api/v1/plugins/{slug}/deactivate |
Deactivate |
DELETE |
/api/v1/plugins/{slug} |
Uninstall |
GET |
/api/v1/themes |
Themes |
POST |
/api/v1/themes/{slug}/activate |
Activate |
GET |
/api/v1/templates |
Templates (FSE) |
PUT |
/api/v1/templates/{id} |
Update template |
GET |
/api/v1/site/at-a-glance |
Dashboard summary |
GET |
/api/v1/site/health |
Health checks |
GET |
/api/v1/activity |
Recent events |
GET |
/api/v1/jobs |
Background jobs status |
GET |
/api/v1/jobs/{id} |
One job |
GET |
/api/v1/events |
SSE stream of resource events |
POST |
/api/v1/webhooks |
Register a webhook |
GET |
/api/v1/webhooks |
List |
DELETE |
/api/v1/webhooks/{id} |
Remove |
POST |
/api/v1/auth/login |
Cookie login |
POST |
/api/v1/auth/logout |
|
POST |
/api/v1/auth/refresh |
Refresh JWT |
POST |
/api/v1/tokens |
Mint API key |
GET |
/api/v1/tokens |
List own API keys |
DELETE |
/api/v1/tokens/{id} |
Revoke |
GET /api/v1/posts?limit=25&cursor=eyJpZCI6...
Response:
{
"data": [ ... 25 items ... ],
"page": {
"limit": 25,
"next": "eyJpZCI6...", // opaque, decodes to (sort_field, id) tuple
"prev": null,
"total_estimated": 1234 // best-effort, may be omitted for large sets
}
}- Cursors avoid the offset-pagination tax (
OFFSET 10000is slow in Postgres). - For backwards-compat with WP REST (which uses
page=N), the shim translates page numbers to cursor approximations.
URL query params, predictable shape:
GET /api/v1/posts?status=draft,published&author=42&category=12&date_after=2025-01-01
Operators (when needed): field[op]=value, e.g. views[gte]=100. We only support a small set: eq, neq, in, nin, gte, lte, like (server-escapes wildcards).
Free-text:
GET /api/v1/posts?q=hello%20world
GET /api/v1/posts?sort=-date,title
Comma-separated; - prefix = descending. Only fields the resource declares sortable (returned in OPTIONS /api/v1/posts).
GET /api/v1/posts?fields=id,title,date
Saves bytes; honoured everywhere.
GET /api/v1/posts/42?embed=author,featured_media,terms.category
Each embedded relation appears under _embedded.{name}. Capped at depth 2 to prevent abuse.
{
"id": 42,
"title": "Hello",
"_embedded": {
"author": { "id": 7, "name": "Ada" },
"featured_media": { "id": 88, "url": "..." },
"terms.category": [{ "id": 12, "name": "Notes" }]
}
}A single shape, RFC 7807-flavoured:
{
"error": {
"type": "https://errors.gonext.dev/validation",
"title": "Validation failed",
"status": 422,
"code": "validation_error",
"detail": "title is required",
"fields": {
"title": ["required"]
},
"trace_id": "..."
}
}We do not wrap successful single-resource responses; GET /posts/42 returns the post object directly. Collection responses are wrapped (data + page). This is the JSON:API/HAL compromise: less noise for single objects, paging context kept tidy for lists.
Accept: application/json only for v1. Future-proofing for application/vnd.gonext.v2+json is possible (see §3.7).
ETag on every resource, Last-Modified where cheap. Clients can If-None-Match for cache; If-Match on writes to avoid lost updates.
Mutating endpoints accept Idempotency-Key: <uuid>. Stored for 24h; replay returns the original response. Critical for uploads and external integrations.
| REST | GraphQL | |
|---|---|---|
| Audience | WP-migrators, mobile/IoT clients, simple scripts, the admin's quick endpoints | Modern frontends (Next.js public render), complex queries (block editor needs lots of joins) |
| Caching | HTTP-level (CDN-friendly) | Persisted queries + per-field cache |
| Compat | WP REST shim cheap to build on top | No equivalent in WP |
| Tooling | OpenAPI / curl / Postman | Codegen → typed hooks for FE |
Both layers sit on top of the same service layer in Go — neither is a transformation of the other. We never had to choose, so we don't.
gqlgen — schema-first, codegen-driven, mature, the dominant choice. Schema lives in internal/graph/schema.graphql; gqlgen generate produces resolvers stubs.
- Schema is the source of truth. Resolvers must satisfy it; no runtime schema mutation.
- Connections for lists (Relay-style) — uniform cursor pagination.
- Interfaces for shared content —
Node,Content. - Unions where appropriate — e.g.
MediaUsage = Post | Page | Template. - Custom scalars for
DateTime,JSON,URI,Slug. - No mutations that bypass capabilities. Every resolver runs the same authz the REST handlers do (shared middleware).
scalar DateTime
scalar JSON
scalar URI
scalar Slug
interface Node { id: ID! }
interface Content {
id: ID!
type: String!
title: String!
slug: Slug!
status: ContentStatus!
createdAt: DateTime!
updatedAt: DateTime!
author: User!
meta(keys: [String!]): JSON
}
enum ContentStatus { DRAFT PENDING PUBLISHED SCHEDULED PRIVATE TRASHED }
type Post implements Node & Content {
id: ID!
type: String!
title: String!
slug: Slug!
status: ContentStatus!
createdAt: DateTime!
updatedAt: DateTime!
publishedAt: DateTime
author: User!
excerpt: String
contentBlocks: JSON! # canonical block tree
contentHTML: String! # rendered, cached
featuredMedia: Media
categories: [Term!]!
tags: [Term!]!
comments(first: Int, after: String, status: CommentStatus): CommentConnection!
meta(keys: [String!]): JSON
permalink: URI!
}
type Page implements Node & Content { ... }
type Media implements Node { ... }
type User implements Node { ... }
type Term implements Node { ... }
type Comment implements Node { ... }
type Query {
node(id: ID!): Node
me: User
posts(
first: Int = 20
after: String
status: [ContentStatus!]
author: ID
category: ID
q: String
): PostConnection!
post(id: ID, slug: Slug): Post
pages(...): PageConnection!
page(id: ID, slug: Slug): Page
media(first: Int, after: String): MediaConnection!
terms(taxonomy: String!, first: Int, after: String): TermConnection!
settings(page: String!): JSON!
site: Site!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
publishPost(id: ID!, at: DateTime): PublishPostPayload!
trashPost(id: ID!): TrashPostPayload!
uploadMedia(input: UploadMediaInput!): UploadMediaPayload!
updateSettings(page: String!, values: JSON!): UpdateSettingsPayload!
...
}
type Subscription {
resourceChanged(types: [String!]): ResourceChangedEvent! # SSE-backed
jobUpdated(id: ID!): Job!
}graphql-codegen produces TS types + TanStack Query hooks:
const { data } = usePostsQuery({ first: 20, status: ['DRAFT'] });Operations are persisted at build time (hash → query) so the client sends { id: "abc123", variables: {...} } to the server — saves bytes, enables a CDN allowlist of known queries, and makes ad-hoc queries from random clients fail (production hardening).
A simple rule:
- The admin uses REST for most CRUD (it's flat, well-cached, plugin endpoints sit there too) and GraphQL for the editor and dashboard widgets where a few queries replace dozens of REST calls.
- The public renderer is GraphQL-first — RSC components compose data fetches into a single query per page.
- Third-party integrations use REST + the WP shim.
A subset of /wp-json/wp/v2/... mapped onto our API. Canonical mount is the bare /wp-json/wp/v2/... path — WordPress clients (mobile apps, headless frontends, integrations) hard-code this prefix, so the bare form is what actually works for migrating clients. A secondary /api/wp-json/wp/v2/... alias is also served for internal tooling consistency with the rest of our /api/... surface, but it is not the primary. Doc 08 owns the deep dive; here is the scope. (Fixed per review C9 — bare /wp-json/... is canonical, matching doc 08.)
Doc 08 §11.1 is the authoritative inventory — the table below is a high-level summary; for the full per-route methods, status, and field-level emulation see doc 08. (Fixed per review C22 — doc 08 owns the deep dive and its broader list wins.)
| WP endpoint | Maps to | Notes |
|---|---|---|
GET /posts, POST /posts, GET/PUT/DELETE /posts/{id} |
/api/v1/posts[...] |
Same filters; page/per_page → cursor. POST accepts WP's content (HTML) — converted to blocks server-side. |
GET/POST/PUT/DELETE /pages[/{id}] |
/api/v1/pages[...] |
|
GET/POST /media, GET /media/{id} |
/api/v1/media[...] |
Multipart upload supported. |
GET/POST/PUT/DELETE /<custom-type> |
/api/v1/types/{type}/items |
For any CPT with show_in_rest=true. |
GET/POST/PUT/DELETE /categories, /tags |
/api/v1/taxonomies/{tax}/terms |
|
GET/POST/PUT/DELETE /users, GET /users/me |
/api/v1/users[...], /api/v1/me |
Sensitive fields capability-gated. |
GET/POST/PUT/DELETE /comments |
/api/v1/comments[...] |
|
GET/POST /settings |
/api/v1/settings |
Whitelisted keys only. |
GET /types, GET /taxonomies, GET /statuses |
/api/v1/types, etc. |
|
GET /menus, GET/POST/PUT/DELETE /menu-items |
internal menu service | WP 5.9+ menus emulation. |
GET/POST/PUT/DELETE /blocks (reusable) |
reusable-block service | |
GET /themes |
read-only single entry | Mimics WP envelope. |
GET /plugins |
read-only | Lists installed gonext plugins. |
GET /search |
search service | Subset of post fields. |
GET /wp-json/ |
site-info envelope stub | Minimal discovery doc. |
/wp-json/wp/v2/block-renderer(we have our own block render contract).- Multisite endpoints.
- Plugin-registered REST namespaces —
/wp-json/<namespace>/<route>returns 404 (the legacy plugin ran in PHP; we do not emulate). v1 final; see doc 08 §19 for open-question discussion of a future stub-mode. xmlrpc.php— 410 Gone.
The shim mimics WP's response keys (title.rendered, content.rendered, excerpt.rendered, _links HAL section). A small adapter layer lives in internal/api/wpcompat/.
The shim accepts four auth mechanisms, listed canonically (must match doc 08 §11.4 exactly). (Fixed per review C10 — cookie + nonce IS supported; doc 08 is the authoritative deep dive and this list is kept in lock-step with it.)
- Cookie +
X-WP-Nonceheader — for browser sessions migrating from WP-style integrations. The nonce is a short-lived token bound to the session; the/wp-json/wp/v2/...middleware validates it as a CSRF token against the active session cookie. - Application Passwords —
Authorization: Basic <user:apppass>where the password is a generated application password (NOT the user's real password). The shim maps Application Passwords onto our personal-access-token system internally; doc 06 owns PAT storage. Importing existing WP Application-Password records during migration gives them a 30-day grace period. - Session cookie + CSRF (our native admin flow) — when the shim is called from our own admin UI, the standard
__Host-gn_sessioncookie plusX-CSRF-Token(double-submit) is accepted as well. - JWT bearer —
Authorization: Bearer <jwt>for our own API token system (see §3.4 and doc 06).
OAuth2 application-installed schemes are out of scope for v1 in the shim.
Three audiences, three auth methods:
| Audience | Method | Storage |
|---|---|---|
| Admin UI | Cookie session (__Host-gn_session, HTTPOnly, SameSite=Lax) |
Redis-backed |
| 1st-party scripts, CLI | JWT access (15m) + refresh token (30d, rotated) | Refresh tokens in Postgres refresh_tokens |
| Programmatic / CI | API key (long-lived, named, scoped) | Hashed in Postgres api_keys |
| 3rd-party apps | OAuth2 (authorization code + PKCE) | Standard OAuth tables |
Browser ──POST /api/v1/auth/login (email, password) ─────► Server
verify password
create session in Redis (TTL 14d sliding)
sign session id with HMAC
◄──── Set-Cookie: __Host-gn_session=...
Browser ──GET /api/v1/posts (cookie attached) ──────────► Server
verify cookie, look up session, attach user
◄──── 200 + CSRF header for next POST
- CSRF: double-submit cookie (
__Host-gn_csrf) +X-CSRF-Tokenheader on mutating requests. SameSite=Lax provides defense in depth. - MFA: TOTP (RFC 6238) and WebAuthn supported; required for users with
manage_options(configurable). - Session pinning: bound to user-agent fingerprint (light: UA + accept-lang, not a full fingerprint).
Client ──POST /auth/login (creds, ?token=true) ──────────► Server
◄──── { access: "...", refresh: "...", exp: 900 }
Client ──Authorization: Bearer <access> ────────────────► Server
◄──── 200
(access expires at 15m)
Client ──POST /auth/refresh { refresh } ────────────────► Server
validate refresh token (single-use, rotated)
◄──── { access: "...", refresh: "...new..." }
- Access tokens are JWTs (HS256, hot rotation of secret via Redis). Claims:
sub,roles,caps_v(version),iat,exp,jti. - Refresh tokens are random 256-bit strings; rotated on every use; replay detection (using the old refresh after rotation) revokes the entire chain and emits a security notification.
- Created from the Profile screen or the CLI.
- Shown once at creation. Stored as
bcrypt(key). - Format:
wpc_<env>_<base32-12chars>_<base32-32chars>— the prefix lets us identify and revoke leaked keys at the edge (CI logs, GitHub secret scanning). - Scopes:
read,write,admin, or a fine-grained capability list. Default to least privilege. - Per-key rate limits and IP allowlists.
Standard authorization code flow with PKCE. App registry under /admin/settings/oauth-apps. Refresh tokens, scopes, consent screen. We don't reinvent any of this.
/admin/profile → Sessions tab lists active sessions (browser, IP, last seen, current); revoke individually or all-but-current.
Plugin routes mounted under /api/plugins/{slug}/... (see §3.1 URL conventions, and doc 02 §6.4 for dispatch) inherit the standard API auth middleware automatically. That means the same cookie / JWT / API-key acceptance applied to /api/v1/... runs in front of every plugin route — plugins do not, and cannot, bypass auth. CSRF (double-submit token on state-changing methods) and rate-limiting buckets (§3.5) apply identically.
Capability gating per route is declared in the plugin's manifest. Each http.serve route entry in the plugin manifest (doc 02 §2.2) carries a capability field:
- A capability slug (e.g.
manage_forms,view_analytics) → the requester must hold that user-facing capability or the request is rejected with403. null(or omitted) → public route, no capability check (still subject to base rate limits and CSRF on writes; example:/sitemap.xml).
The middleware enforces this before the host dispatches http.serve.{slug} to the plugin's WASM hook_handler, so an unauthorized request never reaches plugin code. (Fixed per review C8 / gap B10.)
Core mutations call this contract after a transactional commit; it is not part of the public /api/v1/... surface.
POST /internal/revalidate
Content-Type: application/json
X-GN-Signature: sha256=<hmac of body with the revalidate secret>
X-GN-Timestamp: <unix seconds>
{
"tags": ["post:{uuid}", "post-list:{type-slug}", "term:{uuid}", "global"],
"paths": ["/blog/hello-world"] // optional, for direct path invalidation
}- Auth: HMAC-SHA256 of the canonical body using a shared
INTERNAL_REVALIDATE_SECRET(rotated via the same secret-management surface as webhook signing keys). Timestamp tolerance ±5 minutes; replay protection via short Redis nonce cache. - Caller: the Go core, after committing a write that may invalidate rendered pages (post publish, term rename, menu reorder, settings change). Calls are emitted through the transactional outbox + Asynq invalidation-worker (doc 07 §15.2 / §16.2) — not synchronously in the request hot path.
- Receiver: the Next.js renderer maps tags to its
revalidateTagcalls; unrecognized tags are accepted and counted as a no-op. - Tag vocabulary: doc 07 §16.1 is canonical. Common tags:
post:{id},post-list:{slug},term:{id},term-tree:{taxonomy},user:{id},media:{id},theme,nav:{menu-id},global. - Plugin invalidations go through
host.cache.invalidate(the WASM host-call surface — doc 02 owns that side); they end up writing the same outbox rows and reaching this endpoint.
(Fixed per review gap B4 — formal contract for ISR revalidation triggers.)
Layered, Redis-backed (go-redis/redis_rate or a hand-rolled token bucket — leaning hand-rolled for control).
| Bucket | Limit | Burst | Notes |
|---|---|---|---|
| Per-IP (anonymous) | 60/min | 120 | Public endpoints, login attempts |
| Per-IP (authenticated) | 600/min | 1200 | Generous; real users don't bother |
| Per-user | 6,000/min | 12,000 | Catches compromised cookies |
| Per-API-key | configurable, default 60/min | 2× | Per-key in api_keys.rate_limit_rpm |
| Per-OAuth-token | 600/min | 1200 | |
| Login attempts | 5/15min/IP+username | 0 | Lockout w/ exponential backoff + CAPTCHA |
| Password reset | 3/hour/account | 0 | |
| Plugin sandbox HTTP egress | per-plugin, set in manifest | — | See doc 02 |
| Webhook delivery | global 100/sec | — | Worker pool |
Every response carries:
RateLimit-Limit: 600
RateLimit-Remaining: 581
RateLimit-Reset: 42
Retry-After: 42 (only when 429)
- Repeated 401s from one IP → temp ban + alert in
Securityinbox. - 5xx spike from a plugin endpoint → flag in plugin admin.
- Bot detection: heuristic on UA + request shape; we don't ship a CAPTCHA out of the box but plugins can hook in
auth.pre_login.
Outbound webhooks for integrations (Zapier, n8n, custom).
| Event | Payload |
|---|---|
post.created |
full post |
post.updated |
full post + changed_fields |
post.published |
full post |
post.trashed |
id, title |
post.deleted |
id |
comment.created |
comment + post snippet |
user.created |
user (no password) |
media.uploaded |
media |
plugin.activated |
slug, version |
theme.activated |
slug, version |
(and any plugin-emitted event via emit('plugin.{slug}.{name}')) |
POST /api/v1/webhooks
Content-Type: application/json
{
"url": "https://hooks.example.com/wpc",
"events": ["post.published", "post.trashed"],
"secret": "auto-generated if omitted",
"active": true,
"filters": { "post_type": ["post", "product"] }
}POST https://hooks.example.com/wpc
X-GN-Event: post.published
X-GN-Delivery: 01J9...
X-GN-Signature: sha256=... (HMAC of body with secret)
X-GN-Timestamp: 1715500000
Content-Type: application/json
{ "event": "post.published", "data": { ... }, "site": { "id": ..., "url": ... } }
- Workers (Asynq) deliver with retries: 1, 5, 30s, 2m, 10m, 1h, 6h (then dead-lettered).
- Each delivery logged (status, latency, response body truncated to 1KB) and viewable in the webhook detail screen.
- Replay button per delivery.
- Auto-disable after 50 consecutive failures + email to webhook owner.
/api/v1/...for as long as plausible.- Additive changes go in v1 (new fields, new endpoints, new optional params). Field deprecation is announced via
DeprecationandSunsetheaders (RFC 8594) and the OpenAPI spec. - Breaking changes are very rare and warrant
/api/v2/. v1 lives in parallel for ≥12 months after v2 release; the admin migrates first, then the WP shim, then v1 is sunset.
For experimental endpoints, namespace /api/v1/experimental/.... No SLA, may be removed in any minor.
GraphQL is versioned through schema evolution: fields are added freely, removed via @deprecated (visible in introspection) for ≥6 months. Persisted-query allowlists protect against accidental "all clients break when we delete X" — we keep the persisted query map for retired hashes pointing to a frozen executor.
Accept: application/vnd.gonext.v2+json is technically nicer but operationally worse: harder to debug, awkward in browser, breaks the casual curl. We log the rejection here and may revisit if v2 proves traumatic.
- OpenAPI 3.1 spec auto-generated from Go handler annotations (we use
github.com/swaggo/swag-style comments or generate from struct tags + a custom doc-gen pass — final choice in the build). - Served at
GET /api/v1/openapi.jsonand rendered as Swagger UI / Redoc at/docs/api. - Stored at
docs/openapi.jsonin the repo (CI fails if handlers and spec drift).
- Schema published at
GET /api/v1/graphql/schema.graphql(SDL) andGET /api/v1/graphql/schema.json(introspection). - Sandbox at
/docs/graphql(GraphiQL or Apollo Sandbox embed).
Both specs drive published client SDKs:
| Language | Generator | Output |
|---|---|---|
| TypeScript (REST) | openapi-typescript + a thin fetcher |
@gonext/sdk-rest |
| TypeScript (GraphQL) | graphql-codegen |
@gonext/sdk-graphql (used by admin + public Next.js) |
| Go | oapi-codegen |
github.com/gonext/sdk-go |
| PHP | (planned) openapi-generator |
for WP-migration tooling |
Released on every minor; semver-tagged.
A single Go binary mirroring WP-CLI in spirit. Talks to a local or remote server over the API (auth via API key from ~/.gonext/config.toml or GONEXT_TOKEN). When run on the same host as the server, can also use a Unix socket for emergency local-only commands.
gonext <noun> <verb> [args] [--flags]
| Command | Purpose |
|---|---|
gonext login [--site URL] |
Interactive auth, stores API key |
gonext whoami |
Current user / token info |
gonext site info |
At-a-glance |
gonext site health |
Run all health checks |
gonext post list [--status=draft] |
|
gonext post create --title=... --status=draft |
|
gonext post get <id> |
|
gonext post update <id> --title=... |
|
gonext post publish <id> |
|
gonext post delete <id> [--force] |
|
gonext page ... |
(same shape) |
gonext media import <path> [--alt=...] |
|
gonext user create --email=... --role=editor |
|
gonext user delete <id> |
|
gonext user role-add <id> editor |
|
gonext role list |
|
gonext cap list <role> |
|
gonext plugin install <slug-or-zip> |
|
gonext plugin activate <slug> [--accept-caps] |
|
gonext plugin deactivate <slug> |
|
gonext plugin update [<slug>] [--all] |
|
gonext plugin delete <slug> |
|
gonext theme install/activate/delete <slug> |
|
gonext option get <key> |
|
gonext option set <key> <value> |
(validates against schema) |
gonext option list [--page=general] |
|
gonext db export [--file=backup.sql] |
Calls pg_dump (admin-machine only) |
gonext db import <file> |
|
gonext cache flush [--scope=fragment,fts] |
|
gonext search reindex |
|
gonext job list |
|
gonext job retry <id> |
|
gonext webhook list |
|
gonext webhook create --url=... --events=post.published,post.trashed |
|
gonext import wordpress <export.xml> [--map-authors=...] |
|
gonext export wordpress [--file=site.xml] |
|
gonext shell |
REPL with await api.posts.list(...) |
gonext tail [--filter=plugin:seo] |
Live log/event stream (SSE under the hood) |
gonext version |
Two formats:
- Human (default, tables, colors) for interactive use.
- JSON (
--format=json) for scripting, also--format=csv,--format=yaml,--format=tsv.
- Implemented as a thin client of the REST API — never accesses the DB directly (except
db export, which shells out topg_dumpon the local box). Eliminates an entire class of "the CLI did something the server doesn't know about" bugs. - Bundled with the server binary for easy install (
gonextis a subcommand of the main binary):gonext-server post listworks on a server install. - Distributed via Homebrew, apt, scoop, and a curl-install script.
| Pros of same app | Pros of separate app |
|---|---|
| One deploy, one repo route | Independent bundles (public stays tiny) |
| Shared auth context trivial | Independent deploy cadence |
| Lower infra cost | Easier ownership boundary |
| Easier theming reuse | Plugin UI bundles only ship to admin |
We pick separate. Cost is duplicated layout/auth wiring; mitigated by shared packages in the monorepo. The win is that the public renderer (the SEO-critical surface) is never penalised by admin code.
- REST-only: simplest. But forces the modern frontend to do many round-trips, especially the editor and dashboards. The public Next.js render becomes painful.
- GraphQL-only: elegant. But cuts off WP-migrating tools, CLI uniformity is harder (curl + JSON is the lingua franca), and CDN caching becomes a science project.
- Both: more code to maintain, but each is small relative to the service layer, and they share authz/hooks. We are explicit that the service layer is the source of truth and the two HTTP layers are transports.
JWT in the admin means storing tokens in JS, which means XSS = full account theft. HTTPOnly cookie + Redis-backed session (revocable) is dramatically safer. JWTs are kept for programmatic clients where XSS isn't a concern.
Mantine would ship faster initially. shadcn pays off as soon as we want a non-default look — which is approximately week 3. Owning the components is the right long-term call for a project meant to host themes and plugins with strong design opinions.
We borrow ideas (sparse fieldsets, included resources, pagination links) but skip the formal envelope, the type/id/attributes split, and the relationship objects. They are overkill for our audience and clash with the WP REST shim.
Plugins (WASM) talk to the host through a different ABI (doc 02), so gRPC's tooling story isn't a fit. HTTP/JSON is universal, debuggable, and what migrating WP users expect.
WP gives plugins the entire admin page. Some "admin pages" by plugins are full of jQuery soup. We give plugins React component slots loaded via ES module import maps; they get a window into the admin, not the whole window. Iframes considered but rejected: they're heavy, prevent shared theming, and clash with the inspector/sidebar patterns.
The admin needs server-push for events (cache invalidation, job progress, notifications). SSE is one-way, HTTP/2-friendly, trivial to terminate at a load balancer, and survives our existing auth stack untouched. WebSockets bought us nothing for these use cases and added a separate auth dance.
- OpenAPI auto-generation tool. swag annotations are noisy; deriving from struct tags is cleaner but requires our own pass. Decide in P0.
- Public marketplace API — is it federated (we host an index, others can run mirrors) or centralised? Affects the install flow's TLS/identity story.
- Realtime collaboration on editor — out of v1, but the API needs to not foreclose CRDT-based co-editing (block IDs are stable, content is a tree, so we're OK). Decide whether to ship the WebSocket plumbing in P2 or P3.
- Admin SSR or full SPA after login? Current plan: full SPA after login (the admin shell mounts client-side). Worth a benchmark: would SSR-ing the first list screen meaningfully help TTI? Spike in P1.
- Capability versioning in JWTs (
caps_v). When an admin changes a role, do we revoke existing JWTs, or accept a stale window? Leaning revoke (bumpcaps_vfor affected users; tokens with oldcaps_vfail). Confirm in doc 06. - CLI bundling: ship as part of the server binary, or separate? Separate is cleaner but adds a release artifact.
- Persisted GraphQL queries — store only on the server, require codegen? Or accept ad-hoc queries from admin in dev and lock down in prod? Operational complexity vs flexibility.
- Webhook signing — we use HMAC-SHA256 today; should we also offer asymmetric (Ed25519) signing for users who want to verify without sharing a secret? Probably v2.
- Multi-tenant admin — we are single-site v1, but the admin code should not bake in
tenant_id = 1everywhere. Audit during P1. - Plugin admin route allocation —
/admin/{section}/{plugin}/...or/admin/plugins/{plugin}/...? First reads better, second is safer for collisions. Probably the latter, with a "promote to top-level" allow-list for trusted plugins.