Skip to content
Launch
Esc
↑↓navigate↵open⌘Jpreview
On this page

vinext front-end on headless WordPress over WPGraphQL

Issue: #4. Apps: apps/cms-wordpress-headless and apps/web-wordpress.

Answer: it works end to end locally. A vinext 1.4 App Router app reads a Docker WordPress over WPGraphQL with generated types. WordPress runs only the queries the app registered, as persisted queries in WPGraphQL Smart Cache. Pages stay cached until WordPress publishes, and drafts and unsaved changes preview. Nothing needed a workaround in vinext itself. The surprises were on the WordPress side: where the plugins come from, and WPGraphQL 2’s new preview mechanism.

The plugin versions and the preview and persisted-query approach match Patronage’s most recent production headless site, which already runs WPGraphQL 2.25 behind a vinext front-end.

What was built

  • WordPress (apps/cms-wordpress-headless): wordpress:php8.3-apache and MariaDB in Docker Compose, WP-CLI as a profile. Composer (run in its own image, so the host needs only Docker) installs WPGraphQL 2.25.0, WPGraphQL Smart Cache 2.3.4, WPGraphQL for ACF 3.0.0 and ACF 6.8.10 (free) into the gitignored wp-content/plugins/. Committed code is only ours: the patronage-headless theme and the patronage-headless-revalidate mu-plugin. The theme sets up the menu and the Hero field group in PHP, preview links, the redirect to the front-end, and the locked queries. bin/setup.sh installs, seeds invented content and writes the front-end’s .env.local; it is safe to rerun.
  • Front-end (apps/web-wordpress): vinext 1.4.0 on Vite 8 and React 19.3, Node target. One catch-all route renders pages and posts by URI, a /preview/[id] route renders drafts in draft mode, and three route handlers serve revalidation, preview entry and preview exit. Queries are typed with GraphQL Code Generator’s client preset against a committed schema.graphql. Requests send only a query ID.
  • The queries start from the existing headless starter (content by URI, menu, post fields). Its Yoast SEO and Redirection queries are left out because those plugins are not installed here.

What was verified

All on a production build (vite build, then vinext start) against the Docker WordPress:

Check Result
Home, page, post, 404 and trailing-slash redirect 200, 200, 200, 404, 308
ACF Hero fields (text, textarea, link) over WPGraphQL rendered
ISR first request x-vinext-cache: MISS, then HIT
A database edit that bypasses WordPress hooks stays stale, as expected
Publishing an edit in WordPress the mu-plugin’s PUT revalidates and the next request shows it
wp patronage-headless-revalidate verify (publish, edit, trash a test post) passes: 200 with the title, 200 with the edit, 404 after trash
Revalidate with a wrong secret 401
Anonymous query text, an unknown query ID, or an attempt to register a query through Automatic Persisted Queries refused (Only pre-defined queries are allowed, PersistedQueryNotFound); no document saved
A registered query ID, anonymously runs
Preview of a draft and of a published page’s autosave renders the draft and the unsaved body
Preview without draft mode, or with a tampered or expired token 404, 401, 401
pnpm lint, pnpm typecheck, pnpm test pass (tests cover the preview token and check that the registered-query manifest is current)

Findings

  1. Install WPGraphQL from its GitHub release zips, not Packagist. WPGraphQL moved to a monorepo (wp-graphql/wp-graphql, with tags like wp-graphql/v2.25.0 and wp-graphql-acf/v3.0.0). Its Packagist package stopped at 2.6.0 in January 2026, installs from source without built assets, and conflicts with WPGraphQL for ACF on appsero/client (2.0.5 against 1.2.1). The release zips are self-contained (their own vendor/), so composer.json declares them as package repositories. ACF (free) comes from its official GitHub repository, which has a composer.json. WPackagist serves the same versions (the production site uses it) and works just as well; the release zips avoid a wordpress.org dependency, which matters where that host is blocked. ACF Pro would need its licensed Composer repository; the demo uses free field types only.
  2. Preview with the X-GraphQL-Preview header, not asPreview. WPGraphQL 2.25 deprecates the asPreview argument in favour of request-level preview context. The client sends X-GraphQL-Preview: database_id=<id> with an ordinary contentNode(id:, idType: DATABASE_ID) query. WPGraphQL then overlays the latest revision’s title and content and keeps the post’s ID, URI and status. One gap: ACF fields still come from the saved post, not the autosave. Deprecated arguments are also hidden from HTTP introspection, so schema.graphql comes from wp graphql generate-static-schema (pnpm --filter web-wordpress codegen:schema) and keeps every argument WordPress accepts.
  3. Application passwords replace the JWT plugin. WPGraphQL accepts WordPress application passwords over HTTP Basic auth, so drafts are read as a dedicated previewer editor and no auth plugin is needed. The committed schema snapshot means type checks never need a running WordPress. Patronage’s production site instead exchanges a one-time code from the editor’s Preview click for that editor’s own WordPress token, so a preview runs with the editor’s permissions. That is the better model once Access is in front of WordPress; the shared preview user is the simplest version that works locally.
  4. Preview links are signed, not secret. The starter put the shared secret in the Preview URL, where every editor could copy it. The theme now issues base64url(id.expires).base64url(HMAC-SHA256) tokens that last 10 minutes and name one post. The front-end checks them with Web Crypto, so the check also runs on Workers.
  5. vinext’s caching follows Next 16. fetch(…, { next: { tags } }), revalidateTag(tag, { expire: 0 }) and revalidatePath() all behave as in Next.js. A path without its trailing slash (/about, which the mu-plugin sends) revalidates /about/. Two details matter:
    • The build report marks a dynamic route ƒ (dynamic) unless it exports revalidate. With export const revalidate = 3600 it is ◐ (ISR) and caches; on-demand revalidation does the real work and the timer is a fallback.
    • Keep draftMode() out of the shared layout. The preview banner lives on the preview route, so public pages stay cacheable.
  6. Lock WPGraphQL to the app’s own queries. WPGraphQL Smart Cache runs in “only allowed” mode with Automatic Persisted Queries switched off. The theme forces both settings through graphql_get_setting_section_fields, so they live in git, and administrators stay exempt for GraphiQL. Codegen’s client preset puts each operation’s SHA-256 in its document and in gql/persisted-documents.json. The app sends only {queryId, variables}. pnpm --filter web-wordpress documents writes the theme’s graphql-documents.json, and when that file changes, WordPress registers the documents on its next request (or right away with wp patronage-headless documents). pnpm test fails if the manifest is stale. The query ID must be the SHA-256 of the exact stored text; Smart Cache refuses other IDs unless an authorized user set them as aliases. The theme also keeps Smart Cache’s document post type out of multi-type contentNodes queries. This is the same registration approach the production site uses.
  7. The revalidation mu-plugin ports cleanly. It is Patronage’s existing plugin with one wire format left (PUT {paths, tags} with X-Headless-Secret-Key) and the Vercel bypass removed. Its Cloudflare Access service-token headers stay, for a front-end behind Access.

Not covered, and where it goes next

  • Workers. The app now carries Spike 1’s setup and builds as the launch-vinext-wordpress Worker: cloudflare.config.ts for cf, the KV data adapter for pages and data, the Images binding, and the deploy, secrets and Workers Builds scripts. In local workerd (vite preview) against the Docker WordPress, pages render, ISR serves from KV, wp patronage-headless-revalidate verify passes, and previews work; process.env carries the bindings under nodejs_compat. It is live at https://launch-wordpress.patronage.dev (2026-10-30), built by Workers Builds from apps/web-wordpress and reading a hosted copy of apps/cms-wordpress-headless behind Cloudflare Access (see Spike 5’s doc). Published edits reach the live site through the revalidation webhook. wp patronage-headless-revalidate verify used to fail its immediate-edit check when it ran inside a single PHP process against the live site, while separate WP-CLI calls showed each edit live. The likely cause is the KV adapter’s read caching: each isolate keeps a tag marker for 5 seconds, and KV keeps it up to 60 seconds in other locations, so a check a fraction of a second after the purge can still see the old page. Verify now retries each check and reports the delay. The Workers Cache CDN adapter waits on a live check (see Spike 1’s doc).
  • Cloudflare Access in front of WordPress. GraphQL requests from the Worker will need an Access service token (CF-Access-Client-Id and -Secret headers) next to, or instead of, the application password. The revalidation side already supports Access.
  • Spike 2. components/content-view.tsx renders WPGraphQL data directly. The WordPress adapter should map ContentFields to packages/content-model; block HTML stays WordPress-specific and is passed to packages/ui as children.
  • Smart Cache cache keys as tags. Done after the spike: reads are tagged with the keys in X-GraphQL-Keys, and the theme sends the keys Smart Cache purges (graphql_purge), so an edit revalidates only the reads that include that node. See the headless WordPress README’s “Cache tags”.
  • Not ported from the starter: SEO metadata (Yoast plus its GraphQL extension), redirects (the starter’s middleware called the Redirection plugin’s REST API on every request), and next/image with WordPress media.

Gotchas

  • Bind mounts point at directories, so a branch switch that replaces the theme or mu-plugins folder leaves the container with an empty mount. docker compose up -d --force-recreate wordpress fixes it.
  • The WP-CLI image runs as uid 82 and the Apache image as uid 33; the cli service runs as 33 so both can write the same files.
  • WordPress 6.7+ logs a _load_textdomain_just_in_time notice for WPGraphQL and WPGraphQL for ACF on every WP-CLI call. It is harmless noise.

Was this page helpful?