A ready-to-fork WordPress plugin boilerplate for building modern, fast, React-powered admin experiences, without losing a single drop of WordPress.
Why Vipresso
Keep everything WordPress gives you — users, roles, posts, taxonomies, REST, hooks, plugin ecosystem — and build your product on top with the DX modern teams expect: TypeScript, React, Vite, HMR, typed REST, schema-driven settings, zero ceremony.
Fork, rename once, ship: admin SPA, dashboard widgets, Gutenberg blocks, public shortcodes, metaboxes, cron, WP-CLI, i18n, GitHub-driven releases and auto-updates. Drop a file, the system discovers it — on both the PHP and TypeScript sides.
What you can build with it
- Admin SPA — tab routing, light/dark theme, persisted UI state.
- Widgets & blocks — dashboard widgets and React-hydrated Gutenberg blocks.
- Public shortcodes — separate lightweight bundle, no admin code shipped to visitors.
- Metaboxes — schema-driven save handlers, revisioned meta, per-site placement.
- Typed REST + schema-driven settings — declarative validation, capability filters, masked secrets.
- WP-Cron jobs and WP-CLI commands — self-contained, auto-discovered.
- i18n end-to-end with optional AI auto-translation (Gemini / OpenAI / Anthropic / DeepL).
- Release plumbing —
npm run build:zip, GitHub Actions release, in-place auto-updates,readme.txt-driven "View details" modal. - One-shot whitelabel — rewrites namespace, env prefix, text domain and headers.
Quick start
cp .env.example .env # set name, text domain, dirname, REST namespace npm install docker compose up -d npm run setup # installs WP + activates the plugin via wp-cli npm run dev # Vite + HMR on ${VIPRESSO_DEV_PORT}, proxies WP for everything else
Once running, the environment is available at:
- Live Site + HMR:
http://localhost:${VIPRESSO_DEV_PORT} - WordPress Admin:
http://localhost:${VIPRESSO_DEV_WP_PORT}/wp-admin(wordpress/wordpress)
Requires Node 20.19+, Docker, Docker Compose.
Generators (npm run new)
| Generator | Creates |
|---|---|
route |
New admin SPA tab (page + route meta) |
setting |
Entry in shared/settings.json (text/password/number/checkbox) |
endpoint |
RouteProvider handler + optional typed TS wrapper |
widget |
Dashboard widget or Gutenberg block |
shortcode |
Public shortcode wired to the viewer bundle |
metabox |
Post-edit metabox with manifest defaults + optional fields schema |
cron |
Self-contained scheduled job |
command |
WP-CLI subcommand under wp <text-domain> ... |
php-class |
Static helper or singleton subsystem |
shadcn |
Adds a shadcn/ui component |
Drop files, the system finds them. No registration lists to maintain.
Architecture at a glance
flowchart LR
B[Browser] -->|all requests| V["Vite"]
V -->|HMR + bundle| B
V -.->|proxy| WP["WordPress"]
subgraph Discovery
T[import.meta.glob]
P[Bootstrap.php scan]
end
T --> V
P --> WP
Two bundles, one repo. src/main.tsx → app.js (admin SPA + widgets +
metaboxes) and src/viewer.tsx → viewer.js (shortcodes only). Public visitors
never download a byte of admin code.
Layout.
├── widgets/
├── shortcodes/
├── metaboxes/ # Feature folders (PHP + TSX + manifest)
├── shared/ # settings.json + generated routes.json
├── src/ # React app (App, main, viewer, pages, api)
└── plugin/ # WordPress plugin root
├── classes/ # Bootstrap, Plugin, Assets, Routes, SchemaStore...
├── plugin.php
├── readme.txt
└── uninstall.php
In dev, widgets/, shortcodes/, metaboxes/, shared/, and resources/ are
bind-mounted inside plugin/, so PHP and Node see the same paths. The release
script copies them in before zipping.
REST in one snippet
final class BillingHandler implements \Vipresso\RouteProvider { public static function routes(): array { return [ ['/billing', \WP_REST_Server::READABLE, [self::class, 'list']], ['/billing', \WP_REST_Server::CREATABLE, [self::class, 'create'], null, [ 'amount' => ['type' => 'number', 'required' => true], 'currency' => ['type' => 'string', 'enum' => ['EUR', 'USD']], ]], ]; } }
Auto-discovered. Default capability manage_options (filtrable via
vipresso/rest/default_capability). The optional 5th tuple element is WP's
declarative args schema (validation + sanitization at resolution). Call
from React: api.get<T>("/billing") from src/lib/api.
Structuring complex business logic
Keep RouteProvider thin (HTTP only). Push data access into Repositories
and business rules into single-responsibility Actions / Services:
final class ProcessOrderAction { public function execute(array $data): int { $total = $data['amount'] * 1.22; return OrderRepository::create($total, $data['currency']); } } final class OrderHandler implements \Vipresso\RouteProvider { public static function routes(): array { /* schema */ } public static function create(\WP_REST_Request $req) { return rest_ensure_response((new ProcessOrderAction())->execute($req->get_params())); } }
Settings & the SchemaStore pattern
shared/settings.json is the schema, Settings extends SchemaStore the
runtime. The base class handles caching, type-safe sanitization, masked secrets
(secret: true → •••• xxxx, mask rejected on save) and add_option(..., 'no')
so the row stays out of WP's autoload set. Extra stores: extend SchemaStore,
declare schemaFile() + typeDefault(), optionally bucketKey().
Metaboxes
A scaffolded metabox is bound and visible immediately thanks to its manifest:
{ "kind": "metabox", "slug": "seo", "title": "SEO", "enabled": true,
"screen": ["post", "page"], "context": "side", "priority": "high",
"metaKey": "_vipresso_seo",
"fields": {
"title": { "type": "text" },
"weight": { "type": "number", "min": 0, "max": 100 },
"tags": { "type": "string[]" }
} }With fields, the save handler reads $_POST[metaKey][...], sanitizes via
MetaboxRegistry::sanitizeFields() and persists one array under metaKey
(inputs use name="<metaKey>[field]"). Without fields, falls back to the
legacy single-scalar pattern. Placement is overridable from the Settings page;
revisions wire automatically (WP 6.4+ via wp_post_revision_meta_keys, three-filter fallback below).
Configuration
One .env drives PHP constants, Vite imports, Docker compose and the
WordPress headers in both plugin.php and readme.txt. Headers are regenerated
on every dev/build. Inline comments (MY_KEY=value # note) are supported.
Essential knobs:
| Variable | Purpose |
|---|---|
VIPRESSO_PLUGIN_NAME / _TEXT_DOMAIN / _PLUGIN_DIRNAME |
Plugin identity |
VIPRESSO_VERSION |
Plugin version + asset cache-bust + discovery cache key |
VIPRESSO_REST_NAMESPACE |
REST namespace used by Routes and the TS client |
VIPRESSO_ADMIN_MENU_PLACEMENT |
sidebar (top-level menu) or settings |
VIPRESSO_ADMIN_TOOLBAR |
true to add a top-bar SPA shortcut |
VIPRESSO_DEV_PORT / _DEV_WP_PORT |
Ports: Vite (Default 3333) / WordPress (Default 8888) |
VIPRESSO_LOCALES |
Locales handled by npm run i18n -- all |
VIPRESSO_DEV_{GEMINI,OPENAI,ANTHROPIC,DEEPL}_API_KEY |
Optional auto-translation provider keys |
Full reference (header fields, Docker overrides, setup defaults): .env.example.
Translation providers
npm run i18n -- all extracts strings, syncs every .po file in
VIPRESSO_LOCALES, and (if any provider key is set) auto-translates the
empty entries before compiling .mo and .json. The first available provider
wins; pin a choice with VIPRESSO_DEV_TRANSLATE_PROVIDER=<id> or
-- --provider=<id>.
| Provider | API key env | Model env (default) |
|---|---|---|
| Google Gemini | VIPRESSO_DEV_GEMINI_API_KEY |
VIPRESSO_DEV_GEMINI_MODEL (gemini-3.1-flash-lite) |
| OpenAI | VIPRESSO_DEV_OPENAI_API_KEY |
VIPRESSO_DEV_OPENAI_MODEL (gpt-5.4-mini) |
| Anthropic | VIPRESSO_DEV_ANTHROPIC_API_KEY |
VIPRESSO_DEV_ANTHROPIC_MODEL (claude-haiku-4-5-latest) |
| DeepL | VIPRESSO_DEV_DEEPL_API_KEY |
n/a |
Preserves %s, %d, %1$s, HTML and {{…}} placeholders (mismatched outputs
dropped with a warning), retries batches up to three times with backoff, writes
.po after every batch so crashes never lose work. -- --force retranslates
every string; npm run i18n -- models [--provider=<id>] lists available models
(or DeepL target languages).
Extension hooks
| Hook | Purpose |
|---|---|
vipresso/upgraded |
After plugin update, run your schema migrations |
vipresso/uninstall |
Last step of uninstall.php, drop your own data |
vipresso/rest/default_capability |
Filter the fallback capability for REST routes |
Scripts
| Script | What it does |
|---|---|
dev |
Vite + HMR + WP proxy (regenerates shared/routes.json and headers) |
tunnel |
Public preview of the dev origin via localtunnel |
build |
tsc -b + dual bundle build (scripts/build-bundles.mjs) |
build:zip |
Production build → plugin/constants.php → release/<dirname>.zip |
setup |
One-shot WP install + plugin activation via wp-cli in Docker |
clear-repo |
Removes Vipresso-specific files |
sync:headers |
Rewrites plugin.php and readme.txt headers from .env |
gen:routes |
Rebuilds shared/routes.json from src/pages/*/route.ts |
i18n -- all |
Extracts strings; generates .po/.mo/.json for every locale |
plugin:rename -- <Identity> |
Whitelabels namespace, env prefix and text domain |
lint |
ESLint flat config on the TS sources |
test |
Vitest unit + component tests (jsdom) |
test:watch |
Vitest in watch mode |
test:e2e |
Playwright smoke against the Docker WordPress |
new |
Plop scaffolder (table above) |
Forking checklist
npm run plugin:rename -- <YourPluginName>npm run clear-repoto remove boilerplate-specific files (issue templates, funding, etc.).- Finalize
.env(text domain, dirname, REST namespace, header fields). - Replace
resources/logo.svgandresources/icon.svg. - Rewrite this
README.mdto describe your actual product. - Build features with
npm run new. npm run i18n -- allwhenever strings change.npm run build:zip→ install the resulting ZIP in WordPress.
Testing
Three layers, deliberately small, optimized to catch what really breaks a fork:
- Vitest (
pnpm test) — unit + component tests underjsdom, with React Testing Library. Path aliases andimport.meta.env.VIPRESSO_*work out of the box. Examples:src/lib/utils.test.ts,src/lib/api/client.test.ts,src/components/Nav.test.tsx. - Playwright (
pnpm test:e2e) — one E2E smoke intests/e2e/smoke.spec.ts: login, open the plugin admin page, assert the React root mounted, hit/wp-json/<namespace>and verify the REST index responds. - PHP smoke (
.github/workflows/ci.yml) —php -lon every shipped file +wp plugin is-active+wp eval 'class_exists(...)'inside the Dockerwpclicontainer.
No Pest / PHPUnit by default: mocking WordPress is high-friction and the real
PHP bugs that hurt a fork (fatal on activation, broken headers, upgrade
crashes) are already covered by php -l + the activation smoke. Add Pest in
your fork the day you have a non-trivial Service/Action worth unit-testing.
The single E2E smoke is intentional too — one test that says "plugin boots, SPA mounts, REST answers" catches the vast majority of regressions; beyond that, Playwright cost rises faster than its value.
Off by default (VIPRESSO_CI_ENABLED=false) so a fresh fork doesn't fail
against tests written for the samples you'll likely delete. Flip it to true
once your own tests are in place. Release workflow is unaffected.
Continuous delivery
.github/workflows/release.yml is the out-of-the-box GitHub Actions pipeline:
- Triggers on push and manual
workflow_dispatch. - Releases only when
VIPRESSO_RELEASE_BRANCHmatches the pushed branch (empty = automatic CD off, dispatch still works). - Compares
VIPRESSO_VERSIONagainst the latest GitHub Release; on bump, builds viapnpm run build:zipand attachesrelease/<dirname>-<version>.zipto a newv<version>Release. - Release notes are the
= <version> =block fromplugin/readme.txt(or a minimal default if missing).
To cut a release: bump VIPRESSO_VERSION, add the matching = <version> =
block in plugin/readme.txt, merge to the release branch. No gh release create.
CI and release run in parallel. Both
ci.ymlandrelease.ymlstart on the same push; the release workflow has its own gates (branch + version bump) so it stays idle on regular pushes, and a red CI does not block a release. Add aworkflow_runtrigger torelease.ymlif you want CI to gate CD.
Auto-updates from GitHub
plugin/classes/Updater.php makes a GitHub-hosted plugin behave like a .org
one: update banners, "Update now" button, one-click in-place upgrade. Config:
| Variable | Purpose |
|---|---|
VIPRESSO_UPDATE_GITHUB_OWNER |
Repo owner. Empty = updater off. |
VIPRESSO_UPDATE_GITHUB_REPO |
Repo name. Empty = updater off. |
VIPRESSO_UPDATE_CHECK_INTERVAL |
API cache TTL in seconds (default 3600 = 1h). |
VIPRESSO_UPDATE_GITHUB_API_BASE |
Defaults to https://api.github.com; override for GitHub Enterprise. |
VIPRESSO_UPDATE_GITHUB_TOKEN |
Optional PAT, only for private repos. |
How it works: the updater hits <api>/repos/<owner>/<repo>/releases/latest
(cached), picks the <dirname>-<version>.zip asset, and injects a WP update
entry when the tag is newer than VIPRESSO_VERSION. For private repos, an
http_request_args filter adds Authorization: Bearer <token> only on that
specific asset URL. Cache is flushed on vipresso/upgraded.
Token safety
build:zip strips any env key ending in _TOKEN, _SECRET, _PASSWORD or
_API_KEY before baking constants.php into the distribution ZIP — your
local PAT never ships to customers. For private-repo auto-updates, define
VIPRESSO_UPDATE_GITHUB_TOKEN in wp-config.php on the host that needs it.
Build your own live demo
Vipresso ships an opt-in WordPress Playground generator: a single command produces a wp-playground-blueprint.json that boots a throw-away WordPress with your plugin pre-installed and admin auto-login. Nothing runs on your servers — Playground executes PHP-WASM inside the visitor's browser.
# .env VIPRESSO_DEMO_PLUGIN_ZIP_URL=https://<your-host>/<your-plugin>.zip npm run gen:playground # writes wp-playground-blueprint.json at repo root
Publish both files (blueprint JSON and plugin ZIP) anywhere publicly reachable — a GitHub repo, a GitHub Release asset, S3, your own server — as long as Playground can fetch them.
Your demo URL is then:
https://playground.wordpress.net/?blueprint-url=https://<your-host>/wp-playground-blueprint.json
Contributing
Found a bug? Have a feature idea? We'd love your help! Check out the Contributing Guide to see how to set up the project locally and submit your changes. For security issues, see SECURITY.md. For general questions and ideas, join the Discussions.
License
GPL v2 or later, same as WordPress itself. Use it, fork it, ship it.