GitHub - tommasomeli/vipresso: A modern, lightning-fast boilerplate for building WordPress plugins with React and Vite

10 min read Original article ↗

Vipresso

A ready-to-fork WordPress plugin boilerplate for building modern, fast, React-powered admin experiences, without losing a single drop of WordPress.

CI WordPress 5.0+ License Buy me a coffee

Launch live demo


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 plumbingnpm 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
Loading

Two bundles, one repo. src/main.tsxapp.js (admin SPA + widgets + metaboxes) and src/viewer.tsxviewer.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.phprelease/<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

  1. npm run plugin:rename -- <YourPluginName>
  2. npm run clear-repo to remove boilerplate-specific files (issue templates, funding, etc.).
  3. Finalize .env (text domain, dirname, REST namespace, header fields).
  4. Replace resources/logo.svg and resources/icon.svg.
  5. Rewrite this README.md to describe your actual product.
  6. Build features with npm run new.
  7. npm run i18n -- all whenever strings change.
  8. 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 under jsdom, with React Testing Library. Path aliases and import.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 in tests/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 -l on every shipped file + wp plugin is-active + wp eval 'class_exists(...)' inside the Docker wpcli container.

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_BRANCH matches the pushed branch (empty = automatic CD off, dispatch still works).
  • Compares VIPRESSO_VERSION against the latest GitHub Release; on bump, builds via pnpm run build:zip and attaches release/<dirname>-<version>.zip to a new v<version> Release.
  • Release notes are the = <version> = block from plugin/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.yml and release.yml start 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 a workflow_run trigger to release.yml if 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.