A modern WordPress development boilerplate with Timber (Twig), Tailwind CSS, Alpine.js, Vite (HMR), and Docker — all wired together with an interactive setup CLI.
Works with npm, pnpm, yarn, and bun — auto-detected.
One command to start:
npm run setup # or: pnpm run setup / yarn setup / bun run setupWordPress installed, theme activated, dev server ready. No manual configuration.
| Tool | Role |
|---|---|
| Tailwind CSS 4 | Utility-first CSS, scans .twig templates |
| Alpine.js | Reactive UI interactions (~14 kB), declared in HTML |
| Vite 6 | HMR, CSS/JS compilation, bundling |
| Timber 2 | Twig templating for WordPress |
| ACF | Custom fields with JSON sync (optional) |
| Loco Translate | Translate theme strings from wp-admin (optional) |
| Docker | Caddy + WordPress + MySQL + phpMyAdmin |
| WP-CLI | Automated WordPress setup |
wp-boilerplate/
├── bin/
│ ├── setup.sh # Interactive setup CLI
│ ├── dev.js # Dev orchestrator (sync + Vite + Docker)
│ ├── sync.js # File sync src/ → theme
│ ├── import.sh # Database import with URL fix
│ └── reset.sh # Full project reset with confirmation
├── docker/
│ ├── docker-compose.yml
│ └── Caddyfile # Reverse proxy config (Caddy → WordPress)
├── src/ # ← Your workspace
│ ├── js/main.js # JS entry point (imports CSS + Alpine)
│ ├── css/main.css # Tailwind entry point
│ ├── views/ # Twig templates (layouts, pages, partials)
│ ├── theme/ # PHP (functions.php, inc/, StarterSite.php)
│ ├── acf-json/ # ACF field groups (git-versioned)
│ ├── fonts/ # Web fonts
│ ├── images/ # Static images
│ └── icons/ # SVG icons (bundled into a sprite)
├── public/ # WordPress installation (gitignored)
├── vite.config.js
├── composer.json # Timber
└── package.json # Vite, Tailwind, Alpine
Key principle: src/ is what you code and commit. public/ is the WordPress installation (gitignored). Build tools live at the root, not in the theme.
- Node.js (v18+)
- Docker Desktop
- Composer
# Via GitHub (recommended — creates your own repo from the template)
# Click "Use this template" on https://github.com/basilevanha/wp-twalp
# then clone your new repo:
git clone https://github.com/you/my-project.git
cd my-project
npm run setupOr via CLI:
gh repo create my-project --template basilevanha/wp-twalp --clone
cd my-project
npm run setupThe interactive CLI walks you through 3 steps:
- Project — site name, slug (auto-derived), DB credentials, optional dump import
- WordPress — automatic setup (admin, language, homepage, clean defaults) or vanilla (manual via browser)
- Plugins — ACF (yes/no), Loco Translate (yes/no)
Then it automatically:
- Generates
.env - Installs dependencies (Composer + your package manager)
- Starts Docker containers
- Installs WordPress via WP-CLI (if automatic mode)
- Activates your theme (includes a default front-page template)
- Removes default themes, plugins, and sample content
- Sets permalinks to
/%postname%/ - Launches the dev server
The dev server starts automatically after setup. To start it manually:
npm run dev╔══════════════════════════════════════════╗
║ Development server ready ║
╠══════════════════════════════════════════╣
║ WordPress : http://localhost:8080 ║
║ Vite HMR : http://localhost:5173 ║
║ phpMyAdmin : http://localhost:8081 ║
╚══════════════════════════════════════════╝
- Edit
.twig→ Tailwind rebuilds CSS + full page reload - Edit
.css→ CSS hot-reloaded instantly (no page refresh) - Edit
.php→ full page reload - Docker containers started automatically if needed
- Ports auto-detected — if 8080/8081/5173 are busy, free ports are used automatically
npm run build # Production build (hashed CSS/JS + manifest)
npm run sprite # Rebuild the SVG icon sprite from src/icons/
npm run dump # Export database to database/dump-YYYYMMDD-HHMMSS.sql
npm run import # Import a database dump (interactive file picker)
npm run stop # Stop Docker containers
npm run reset # Delete everything (with confirmation + optional dump)Style directly in your .twig templates:
<button class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90">
{{ __('Contact', 'wp-twalp') }}
</button>Tailwind scans all .twig files and only outputs the CSS classes you actually use. Copy-paste components from Flowbite, HyperUI, DaisyUI, or any Tailwind component library.
The boilerplate includes a ready-to-use design system in src/css/main.css based on semantic color tokens:
| Token | Usage |
|---|---|
primary |
Brand color — buttons, links, accents |
secondary |
Subtle backgrounds, secondary actions |
muted |
Disabled states, placeholders |
destructive |
Errors, delete actions |
success / warning |
Feedback states |
card |
Card backgrounds |
border / input / ring |
Form elements, borders |
All colors use the OKLCH color space for perceptual uniformity. Dark theme is supported via the .dark class on <html>.
Use them as Tailwind classes: bg-primary, text-muted-foreground, border-border, ring-ring, etc. To customize the palette, edit the CSS custom properties in :root and .dark.
Declare reactive behavior directly in your Twig templates:
<div x-data="{ open: false }">
<button @click="open = !open">Menu</button>
<nav x-show="open" x-transition>
{{ fn('wp_nav_menu', { theme_location: 'primary' }) }}
</nav>
</div>Perfect for menus, modals, tabs, accordions — anything that would normally require querySelector + addEventListener boilerplate. Alpine is loaded globally via main.js.
Drop SVG files into src/icons/ and use them anywhere with the icon() Twig function:
{{ icon('arrow-right') }} {# sizes with surrounding text (1em) #}
{{ icon('arrow-right', 'h-5 w-5') }} {# Tailwind size utilities #}
{{ icon('arrow-right', 'h-6 w-6 text-primary') }} {# size + color #}Signature: icon(name, class) — name is the file name in src/icons/ without the .svg extension; class is an optional string of CSS classes. Sizing and color are driven entirely by Tailwind — there are no width/height arguments, since HTML size attributes would be overridden by any class and would not follow responsive variants.
A build step (bin/build-sprite.js) concatenates every src/icons/*.svg into a single sprite — one <symbol id="icon-{name}"> per file — written to themes/{name}/assets/icons/sprite.svg. The icon() function outputs an <svg><use href="…#icon-{name}"></use></svg> referencing that sprite.
The sprite is rebuilt:
- on
npm run dev— once at startup, then on any change undersrc/icons/(watched) - on
npm run build— for production - on demand —
npm run sprite
Source SVGs are normalised minimally: width/height on the root <svg> are stripped so icons size from CSS, while viewBox and presentation attributes (fill, stroke, stroke-width, stroke-linecap, stroke-linejoin, stroke-miterlimit) are carried over to the <symbol>. This means icons copied straight from Lucide, Feather, or Heroicons work as-is.
Icons inherit color via currentColor — set it with Tailwind text-* utilities. The base .icon rule in src/css/main.css defaults size to 1em and applies fill: currentColor and vertical-align: middle; any Tailwind size utility overrides the default.
src/ is your source of truth. The sync system copies files to the WordPress theme directory:
| Source | Destination |
|---|---|
src/theme/ |
→ public/wp-content/themes/{name}/ |
src/views/ |
→ themes/{name}/views/ |
src/fonts/ |
→ themes/{name}/assets/fonts/ |
src/images/ |
→ themes/{name}/assets/images/ |
src/icons/ |
→ themes/{name}/assets/icons/sprite.svg (bundled into one sprite) |
src/acf-json/ |
→ themes/{name}/acf-json (Docker bind mount) |
In dev mode, changes are watched and synced automatically.
The PHP bridge (inc/vite.php) detects the environment:
- Dev: reads
dist/hotfile → injects Vite's@vite/clientfor HMR → loads JS/CSS from dev server - Prod: reads
dist/.vite/manifest.json→ enqueues hashed CSS/JS files
ACF field groups are stored in src/acf-json/ (versioned in git). Docker bind-mounts this directory into the theme's acf-json/, so changes made in wp-admin are written directly to your repo.
wp-admin → JSON is automatic: edit a field group in the admin, the file is rewritten in src/acf-json/.
JSON → wp-admin is a manual sync: if you edit a .json file directly, bump its "modified" value (any higher Unix timestamp) so ACF shows a "Sync available" banner on the Field Groups page, then click Sync.
To remove ACF support, answer "no" during setup — the CLI removes all related files.
Theme templates use {{ __('String', 'wp-twalp') }} (Twig) and __('String', 'wp-twalp') (PHP) — everything is translation-ready out of the box. The text domain wp-twalp is declared in style.css and loaded via load_theme_textdomain() in src/theme/inc/i18n.php, which points at src/theme/languages/.
Two workflows:
- Loco Translate (recommended) — enable it during
npm run setup. Translate from wp-admin → Loco Translate → Themes. The generated.po/.mofiles land insrc/theme/languages/via a Docker bind mount, ready to commit. - Manual — generate a POT with
wp i18n make-potand edit translations in Poedit, then drop the files intosrc/theme/languages/.
Note: Loco Translate only handles theme strings. For a true multilingual site (multiple visitor-facing languages), add a plugin like Polylang or WPML — these are not covered by the setup CLI, install them manually if your project needs them.
In Loco Translate → Settings → Site options, make sure Scan PHP files with extensions contains php twig. Without twig, strings inside .twig files are never extracted into the POT.
When you click New language in Loco, it asks where to store the files. Always pick Author — anything else loses the work:
- ❌ System — writes to
wp-content/languages/themes/, outside the theme. Lost on Docker recreation and not versioned. - ❌ Custom (when it points at
wp-content/languages/loco/themes/) — same problem. - ✅ Author — writes to
src/theme/languages/, the folder declared inload_theme_textdomain(). Git-versioned and persistent.
Remember that public/ is gitignored, so any translation saved there will be silently lost on the next reset.
- Add a string in a
.twigor.phpfile:__('My string', 'wp-twalp'). - In Loco: Themes → wp-twalp → Sync to refresh the
.pot. - Click the target language, translate, Save —
.poand.mofiles are written tosrc/theme/languages/.
Use sprintf() for variables, and always use numbered placeholders (%1$s, %2$s) as soon as there are two or more — translators may need to reorder them:
sprintf( __( 'Posted by %1$s on %2$s', 'wp-twalp' ), $author, $date );{{ __('Posted by %1$s on %2$s', 'wp-twalp')|format(author, date) }}For plurals, use _n():
sprintf( _n( '%d comment', '%d comments', $count, 'wp-twalp' ), $count );WordPress loads the .mo matching Settings → General → Site Language. To test fr_BE, switch the site language to Français de Belgique — WordPress will pick up src/theme/languages/fr_BE.mo on the next request.
Each project gets its own:
- Container names — prefixed with project slug (
wp-twalp-wordpress-1) - Database volume —
{slug}_db_data - Network —
{slug}_default - DB credentials — unique name/user/password derived from slug
Multiple projects can run simultaneously on different ports.
A Caddy reverse proxy sits in front of WordPress. This means:
- WordPress always thinks it's on its configured URL (
localhost:8080) — no broken redirects - If
8080is taken by another process, the setup and dev server automatically find the next free port and bind Caddy there instead - WordPress itself never needs reconfiguring when the port changes
npm run dump
# → database/dump-20260319-143052.sqlDumps are gitignored by default. Each dump is timestamped.
Two ways to import a dump:
During setup — if dumps exist in database/, the setup CLI offers to restore one instead of starting with an empty database.
On a running project:
npm run import # Interactive file picker
npm run import -- database/dump-20260319-143052.sql # Direct pathAfter import, URLs are automatically fixed via wp search-replace (serialization-safe).
npm run dump # Save your work first
npm run reset # Confirmation prompt → optional dump → wipe everything
npm run setup # Start fresh (will offer to restore from dump)Twig templates follow the Timber structure:
src/views/
├── layouts/
│ └── base.twig # Full HTML shell (header, main, footer)
├── templates/
│ ├── front-page.twig # Homepage (removed if "latest posts" chosen)
│ ├── index.twig # Blog / fallback
│ ├── single.twig # Single post
│ ├── page.twig # Static page
│ ├── archive.twig # Archive/category
│ ├── search.twig # Search results
│ ├── author.twig # Author archive
│ ├── 404.twig # Not found
│ └── single-password.twig # Password-protected post
└── partials/
├── head.twig # <head> meta tags
├── menu.twig # Navigation
├── footer.twig # Site footer
├── comment.twig # Single comment
├── comment-form.twig # Comment form
├── pagination.twig # Post pagination
├── tease.twig # Post teaser/card
└── tease-post.twig # Post-specific teaser
layouts/ contains base templates that page templates extend via {% extends %} — add a new layout here when the global page structure changes significantly (e.g. fullwidth, sidebar, blank). partials/ contains reusable fragments included via {% include %}.
All UI strings are translation-ready with {{ __('String', 'text-domain') }}.
- Pre-flight checks — verifies Node, package manager, Composer, Docker before starting
- Resume after failure — answers are saved to
.setup-state. If setup crashes, re-run and it offers to resume - Port detection — finds free ports if 8080/8081/5173 are busy
- Volume detection — warns if a database already exists for the project name
- Vanilla mode — skip WP-CLI configuration and set up WordPress manually via browser
Auto-detected. Use whichever you prefer:
npm run setup # npm
pnpm run setup # pnpm
yarn setup # yarn
bun run setup # bunAll CLI messages adapt to show the correct command for your PM.
npm run buildProduces a self-contained theme with:
- Compiled and minified CSS/JS with content hashes
vendor/directory (Timber) includedmanifest.jsonfor cache-busting- No dev dependencies or source maps
Deploy the theme directory to any WordPress installation.
In dev, Vite injects CSS through the JS module graph to enable HMR — which causes a brief flash of unstyled content on first load. This is expected and only happens in dev. To verify the production output behaves correctly:
# Stop the dev server first (Ctrl+C) so dist/hot is removed
npm run buildThen reload the site at http://localhost:8080. The PHP bridge detects the absence of dist/hot and switches to manifest mode, enqueuing hashed CSS/JS the standard WordPress way — no FOUC. Run npm run dev again to return to development.
Contributions are welcome! See CONTRIBUTING.md for guidelines.
