Modern browsers run JavaScript modules natively. For a small theme, that means you can skip the bundler entirely: write import and export, load the entry file with type="module", and let the browser fetch the rest. No webpack config, no build to wait for, and the code in the browser is the code you wrote.
I’ve used this setup on a WordPress theme for a while and like it. There’s one problem you’ll hit as soon as the site sits behind a CDN like Cloudflare, though, and it isn’t obvious until visitors start seeing old JavaScript after a deploy.
The setup
The theme’s scripts live in assets/js/ as plain modules:
// assets/js/app.js
import { initNav } from './modules/nav.js';
import { initFilters } from './modules/filters.js';
import { initGlobe } from './modules/globe.js';
initNav();
initFilters();
initGlobe();
WordPress enqueues the entry point and a small filter adds type="module" to its tag:
wp_enqueue_script(
'mytheme-app',
get_theme_file_uri( 'assets/js/app.js' ),
array(),
filemtime( get_theme_file_path( 'assets/js/app.js' ) ),
array( 'in_footer' => true )
);
add_filter( 'script_loader_tag', function ( $tag, $handle ) {
return 'mytheme-app' === $handle
? str_replace( '<script ', '<script type="module" ', $tag )
: $tag;
}, 10, 2 );
Module scripts are deferred by default, so they don’t block rendering. Heavy dependencies can be loaded only when needed with a dynamic import(), which keeps them off pages that don’t use them.
The cache problem
The entry point is versioned. WordPress adds ?ver= plus the file’s modification time, so every deploy that changes app.js gives it a new URL, and Cloudflare and browsers fetch it fresh.
The imports inside it aren’t versioned. import './modules/nav.js' is always the same URL, whatever is in the file. If you change nav.js but not app.js:
- The entry point’s URL doesn’t change, so the edge serves its cached copy.
- Even if it did change, its import of
./modules/nav.jsresolves to the same URL as before, and Cloudflare has that cached too, for as long as the cache headers allow. On many hosts that’s weeks.
The result is a mix of old and new modules. Sometimes that’s harmless. Sometimes a new module calls a function an old one doesn’t export yet, and the whole script fails.
Purging the CDN after each deploy works if you have access to do it. Often you don’t, and anyway, a deploy shouldn’t depend on remembering a manual step.
The fix: version every import at build time
I don’t want a bundler, but a tiny build step is fine. It copies assets/js/ to dist/js/ and appends the same version string to every relative import. The version is a hash of all the source files, so it changes whenever any module changes:
// build.js
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const SRC = path.join(__dirname, 'assets/js');
const OUT = path.join(__dirname, 'dist/js');
const listJs = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return listJs(full);
return entry.name.endsWith('.js') ? [full] : [];
});
const files = listJs(SRC);
// One hash for the whole tree, sorted so read order doesn't matter.
const hash = crypto.createHash('sha1');
[...files].sort().forEach((file) => hash.update(fs.readFileSync(file)));
const version = hash.digest('hex').slice(0, 10);
// from './x.js', import('./x.js'), import './x.js' (relative, no query yet)
const importRe = /((?:froms+|imports*(s*|imports+)['"])(..?/[^'"?]+.js)(['"])/g;
fs.rmSync(OUT, { recursive: true, force: true });
files.forEach((file) => {
const out = path.join(OUT, path.relative(SRC, file));
fs.mkdirSync(path.dirname(out), { recursive: true });
const src = fs.readFileSync(file, 'utf8');
fs.writeFileSync(out, src.replace(importRe, (_m, pre, spec, post) => `${pre}${spec}?v=${version}${post}`));
});
console.log(`dist/js (${files.length} files, v=${version})`);
The output is the same code with every relative import pointing at a versioned URL:
import { initNav } from './modules/nav.js?v=907f7f3d26';
WordPress then enqueues dist/js/app.js instead of the source file. When any module changes, the hash changes, every import URL changes with it, and no layer of cache can serve a stale module.
Why one hash for everything
It’s tempting to hash each file on its own so that unchanged modules keep their URLs and stay cached. But if nav.js imports prefs.js, and prefs.js changes, then nav.js‘s import line has to change too. So nav.js changes as well, and so on up the tree. Getting that right means building a dependency graph, which is a bundler’s job. One hash for the whole tree means every module is refetched after any change. For a theme with a dozen small files, that’s a few kilobytes, once per deploy.
Caveats
- The regex is deliberately narrow. It only handles relative
.jsimports without an existing query string, which is all this code uses. If you import bare package names or build specifiers dynamically, you need a real parser or an import map. - Commit the output, or build on deploy. The theme doesn’t work without
dist/js/. - Request waterfalls. Without bundling, the browser discovers nested imports one level at a time. For a few small modules over HTTP/2 that’s fine. For a deep tree, add
<link rel="modulepreload">tags or reconsider a bundler.
It’s about 40 lines of Node, and it fixed a problem that cost me an afternoon of “but I deployed it”. If your site’s JavaScript or CSS doesn’t update reliably after a deploy, that’s one of the speed and maintenance problems I fix.