Modern WordPress theme development does not have to feel stuck in the past.
You can still use the parts of WordPress that are excellent - templates, hooks, custom fields, routing, editorial workflows - while giving your theme a modern frontend build setup with Vite.
In this tutorial, we will build a Vite-powered WordPress theme setup that gives you:
- Sass support
- JavaScript modules
- fast local development
- hashed production assets
- a Vite manifest
- WordPress-safe asset enqueueing
- graceful fallbacks when the production build is missing
The examples are based on a custom portfolio theme, but the structure works for most classic WordPress themes.
The Goal
We want a theme structure like this:
your-theme/
assets/
dist/
inc/
enqueue.php
src/
js/
main.js
scss/
main.scss
functions.php
style.css
vite.config.js
package.json
During development, we write code in src/.
For production, Vite builds optimized files into dist/, and WordPress loads the correct hashed filenames from Vite's manifest.
1. Create a Basic Theme
At minimum, a classic WordPress theme needs:
style.css
functions.php
index.php
Your style.css should include the theme header:
/*
Theme Name: Your Theme
Author: Your Name
Version: 1.0.0
*/
Even if Vite handles your real CSS, WordPress still uses this file to identify the theme.
2. Install Vite and Sass
Inside your theme folder, initialize npm if you have not already:
npm init -y
Then install Vite and Sass:
npm install -D vite sass
If your theme uses frontend libraries, install them normally:
npm install gsap lenis
Your scripts can be simple:
{
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build"
},
"devDependencies": {
"sass": "^1.89.0",
"vite": "^5.4.19"
}
}
3. Create Your Frontend Entrypoint
Create src/js/main.js:
import '../scss/main.scss';
console.log('Theme scripts loaded');
Then create src/scss/main.scss:
:root {
--color-bg: #101012;
--color-text: #f5f5f5;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: system-ui, sans-serif;
}
The important detail is that your JavaScript entry imports your Sass file. Vite will follow that import and generate the final CSS asset during the build.
4. Configure Vite for WordPress
Create vite.config.js in the theme root:
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
root: '.',
base: './',
build: {
manifest: true,
emptyOutDir: true,
outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'src/js/main.js')
}
}
}
});
The key pieces:
-
manifest: truetells Vite to generate a manifest file. -
outDir: 'dist'keeps production assets inside the theme. -
rollupOptions.inputdefines the source entrypoint. -
base: './'helps generated asset references stay relative.
After running a production build, Vite creates files like:
dist/
assets/
main-B8x7k2.css
main-D9s1a4.js
.vite/
manifest.json
The hashed filenames are great for caching, but WordPress needs a way to discover them. That is where the manifest comes in.
5. Load the Manifest in PHP
In functions.php, define a few theme constants:
<?php
defined( 'ABSPATH' ) || exit;
define( 'THEME_DIR', get_template_directory() );
define( 'THEME_URI', get_template_directory_uri() );
define( 'THEME_VER', '1.0.0' );
require_once THEME_DIR . '/inc/enqueue.php';
Then create inc/enqueue.php:
<?php
defined( 'ABSPATH' ) || exit;
function theme_vite_manifest() {
$path = THEME_DIR . '/dist/.vite/manifest.json';
if ( ! file_exists( $path ) ) {
return array();
}
$manifest = json_decode( (string) file_get_contents( $path ), true );
return is_array( $manifest ) ? $manifest : array();
}
This helper reads the manifest only if it exists. If the build folder is missing, it returns an empty array instead of breaking the site.
6. Enqueue the Built Assets
Now we can enqueue the generated CSS and JS:
function theme_enqueue_assets() {
$manifest = theme_vite_manifest();
$entry = isset( $manifest['src/js/main.js'] ) ? $manifest['src/js/main.js'] : null;
if ( $entry && ! empty( $entry['css'][0] ) ) {
wp_enqueue_style(
'theme-app',
THEME_URI . '/dist/' . $entry['css'][0],
array(),
THEME_VER
);
} else {
wp_enqueue_style(
'theme-style',
get_stylesheet_uri(),
array(),
THEME_VER
);
}
if ( $entry && ! empty( $entry['file'] ) ) {
wp_enqueue_script(
'theme-app',
THEME_URI . '/dist/' . $entry['file'],
array(),
THEME_VER,
true
);
wp_script_add_data( 'theme-app', 'type', 'module' );
}
}
add_action( 'wp_enqueue_scripts', 'theme_enqueue_assets' );
This does three useful things:
- loads the hashed production CSS file from Vite
- loads the hashed production JS file from Vite
- adds
type="module"to the script tag
It also falls back to style.css if the production CSS is not available.
7. Run the Build
Now run:
npm run build
If everything is wired correctly, Vite should create your dist/ folder and the manifest file.
Then refresh WordPress and inspect the page source. You should see something like:
<link rel="stylesheet" href="/wp-content/themes/your-theme/dist/assets/main-B8x7k2.css">
<script type="module" src="/wp-content/themes/your-theme/dist/assets/main-D9s1a4.js"></script>
That means WordPress is loading your compiled Vite assets.
8. Optional: Add a Development Fallback
For a simple theme, you can build before refreshing WordPress.
But if you want a smoother local workflow, you can also detect the Vite dev server and load assets from it during development.
A minimal version looks like this:
function theme_is_vite_dev_server_running() {
$response = wp_remote_get( 'http://127.0.0.1:5173/src/js/main.js', array(
'timeout' => 1,
) );
return ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response );
}
Then inside your enqueue function:
if ( theme_is_vite_dev_server_running() ) {
wp_enqueue_script(
'vite-client',
'http://127.0.0.1:5173/@vite/client',
array(),
null,
true
);
wp_script_add_data( 'vite-client', 'type', 'module' );
wp_enqueue_script(
'theme-app',
'http://127.0.0.1:5173/src/js/main.js',
array(),
null,
true
);
wp_script_add_data( 'theme-app', 'type', 'module' );
return;
}
Now you can run:
npm run dev
and let Vite serve your source modules locally.
For production, always run:
npm run build
and let WordPress load from dist/.
9. Optional: Inline Critical CSS
For performance-focused themes, you can go one step further and extract a small critical CSS file during the Vite build.
Here is a simplified Vite plugin example:
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
export default defineConfig({
build: {
manifest: true,
outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'src/js/main.js')
}
}
},
plugins: [
{
name: 'theme-critical-css',
closeBundle() {
const manifestPath = resolve(__dirname, 'dist/.vite/manifest.json');
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const entry = manifest['src/js/main.js'];
if (!entry?.css?.[0]) return;
const css = readFileSync(resolve(__dirname, 'dist', entry.css[0]), 'utf8');
const critical = css.slice(0, 7000);
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
writeFileSync(resolve(__dirname, 'dist/critical.css'), critical);
}
}
]
});
Then inline it in WordPress:
add_action( 'wp_head', function () {
$critical = THEME_DIR . '/dist/critical.css';
if ( file_exists( $critical ) ) {
echo '<style id="theme-critical-css">' . wp_strip_all_tags( (string) file_get_contents( $critical ) ) . '</style>';
}
}, 1 );
This is intentionally simple. For a serious production site, use a more deliberate critical CSS strategy instead of slicing the first few kilobytes blindly.
Common Gotchas
The manifest path is wrong
Vite 5 writes the manifest to:
dist/.vite/manifest.json
Make sure your PHP code reads from that path.
WordPress is loading old files
Clear any caching plugin, CDN cache, and browser cache. Hashed files help, but WordPress or your host may still cache HTML.
Your script is not treated as a module
Add this after wp_enqueue_script():
wp_script_add_data( 'theme-app', 'type', 'module' );
Your theme breaks before the first build
Always guard manifest loading with file_exists(). A missing dist/ folder should not white-screen the site.
Final Checklist
Before shipping:
-
npm run buildsucceeds -
dist/.vite/manifest.jsonexists - WordPress loads CSS from
dist/assets - WordPress loads JS from
dist/assets - the script tag has
type="module" - the site still works if
dist/is missing - production caches are cleared after deployment
Wrap-Up
This setup gives WordPress a modern frontend workflow without fighting the platform.
WordPress still handles templates, routing, admin content, and PHP hooks. Vite handles Sass, JavaScript modules, bundling, and hashed production assets.
That split is the sweet spot: WordPress where WordPress is strong, Vite where the frontend needs speed.
About me
I am Amr Osama, a senior web developer building fast, SEO-ready WordPress and React platforms. I share practical notes from real projects on custom WordPress development, performance, technical SEO, and modern frontend workflows.
Top comments (0)