WordPress 6.5 introduced wp_enqueue_script_module(), the official API for loading JavaScript ES modules natively in the browser — no bundler required for simple scripts. ES modules use import/export, are deferred by default, and run in strict mode automatically.
Problem: WordPress themes and plugins traditionally load JavaScript as classic scripts with wp_enqueue_script() — using native ES module import/export syntax requires a bundler or breaks browser compatibility.
Solution: Use wp_enqueue_script_module() (introduced in WordPress 6.5) to register native ES modules. WordPress generates an import map automatically for known module identifiers, adds type="module" to the script tag, and handles dependencies declared in the module's imports array — no bundler required for simple module graphs.
The examples below enqueue a script module for a Gutenberg block front end, import from another module, and show the key differences between the classic wp_enqueue_script() and the new module API.
<?php
// Enqueue an ES module — WordPress 6.5+
add_action( 'wp_enqueue_scripts', function() {
// wp_enqueue_script_module() vs wp_enqueue_script():
// - Always deferred (no 'async' or 'footer' parameters needed)
// - Loaded with <script type="module"> — runs in strict mode
// - Uses import maps for dependency resolution
// - Handles circular imports correctly
// - Each module runs once even if enqueued multiple times
wp_enqueue_script_module(
'@myplugin/cart-counter', // module identifier (handle)
plugins_url( 'js/cart-counter.js', __FILE__ ),
[ '@wordpress/interactivity' ], // module dependencies (handles)
'1.0.0'
);
// Register without enqueueing (lazy-loaded later)
wp_register_script_module(
'@myplugin/analytics',
plugins_url( 'js/analytics.js', __FILE__ )
);
} );
// In block.json, use 'viewScriptModule' (not 'viewScript') for ES modules:
// {
// "viewScriptModule": "file:./view.js"
// }
// WordPress auto-generates the module handle: "myplugin/my-block//view"
The module file itself uses native ES module syntax:
// js/cart-counter.js — runs as a native ES module
// Import from another WordPress module via its handle
import { store, getContext } from '@wordpress/interactivity';
// Import from a local module — relative paths work normally
import { formatPrice } from './utils/format.js';
// Import maps handle bare specifiers like '@wordpress/interactivity'
// WordPress 6.5+ outputs an importmap <script> tag automatically
// based on what's registered via wp_enqueue_script_module().
const { state, actions } = store( 'myplugin/cart', {
state: {
get formattedTotal() {
return formatPrice( getContext().total );
},
},
actions: {
updateCount( newCount ) {
getContext().count = newCount;
},
},
} );
// Top-level await works in ES modules (not possible in classic scripts)
const config = await fetch( '/wp-json/myplugin/v1/config' ).then( r => r.json() );
state.taxRate = config.taxRate;
<?php
// Classic script vs Module API comparison:
// Classic (still valid, use when you need IE11 or defer control):
wp_enqueue_script( 'my-script', plugins_url( 'js/main.js', __FILE__ ),
[ 'jquery' ], '1.0', [ 'strategy' => 'defer' ] );
// Module (preferred for new code using @wordpress/interactivity or modern JS):
wp_enqueue_script_module( '@myplugin/main', plugins_url( 'js/main.js', __FILE__ ),
[ '@wordpress/interactivity' ] );
// Output an importmap entry for a custom CDN module
add_filter( 'wp_script_module_data', function( array $data ): array {
$data['imports']['lodash-es'] = 'https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js';
return $data;
} );
NOTE: ES modules are supported in all modern browsers. WordPress's import map polyfill handles older browsers via the es-module-shims library. Do not mix wp_enqueue_script() and wp_enqueue_script_module() for the same logical script — they cannot share dependencies through the WordPress dependency injection system.