How to enqueue ES module scripts and use dynamic import in WP in WordPress

Contents

Introduction

This article explains, in exhaustive detail, how to enqueue ES module scripts and use dynamic import() in WordPress. It covers WordPress-specific techniques to register and print module scripts, provide localized data to modules, create legacy fallbacks with nomodule, handle attributes like crossorigin or integrity, and demonstrates reliable patterns for dynamic import usage inside module files. It also discusses browser behavior, common pitfalls, and build-tool considerations.

Why use ES modules and dynamic import in WordPress?

  • Native module semantics: import/export, lexical scoping, strict mode by default, top-level await support in modern browsers.
  • Better code-splitting: dynamic import() returns a Promise and can lazy-load features or large chunks only when needed.
  • No bundler required: for smaller projects you can ship native ESM files to browsers and rely on the browser to handle module loading.
  • Interoperability: modules make it easier to structure code and work with modern libraries that provide ESM builds.

Browser support and important behaviors

  • Modern evergreen browsers support type=module and import(). Use a nomodule fallback for legacy browsers.
  • Module scripts are deferred by default (they run after the document is parsed) unlike classic scripts which might block.
  • Import paths inside modules are resolved relative to the module file URL (not the document). Use correct relative or absolute paths.
  • Dynamic import() returns a Promise resolving to the module namespace object. Use await import(…) or import(…).then(…).
  • Cross-origin behavior: modules follow CORS rules imported module URLs must be accessible and served with appropriate headers. crossorigin attributes may be needed for some workflows.

How WordPress outputs module scripts

WordPress provides APIs to register and enqueue scripts: wp_register_script() and wp_enqueue_script(). To tell WordPress to output a script tag with type=module, use wp_script_add_data(handle, type, module). For a legacy fallback use wp_script_add_data(handle, nomodule, true) on the fallback script registration or use the script_loader_tag filter to tweak the printed tag.

Basic pattern: register and enqueue a module script

This is the simplest form: register a JS file and mark it as type=module.

lt?php
function theme_enqueue_module() {
    handle = my-theme-module
    src = get_template_directory_uri() . /assets/js/main-module.js
    deps = array() // Module can import its own dependencies keep this empty unless you have non-module dependencies.
    ver = filemtime( get_template_directory() . /assets/js/main-module.js )
    wp_register_script( handle, src, deps, ver, true ) // load in footer (true)
    // Tell WP to print type=module
    wp_script_add_data( handle, type, module )
    wp_enqueue_script( handle )
}
add_action( wp_enqueue_scripts, theme_enqueue_module )
?gt

Notes:

  • Use filemtime or another method for cache-busting the script file.
  • Module script contents should use import/export syntax. The browser will handle loading imported modules referenced with relative paths inside the module file.
  • You typically do not use WordPress dependencies here (like jQuery) as ESM imports are preferable for module-managed dependencies.

Static imports inside the module file

A module file can use standard static imports. Keep in mind that import paths are resolved relative to the module file URL.

// assets/js/main-module.js
import { initWidget } from ./widget.js
import { util } from ./utils/util.js

document.addEventListener(DOMContentLoaded, () =gt {
    initWidget()
    console.log(Util version:, util.version)
})

Dynamic import() usage

Dynamic import() allows lazy-loading modules on demand, returning a Promise that resolves to the module namespace object. This is ideal for loading optional UI code, code behind user interactions, or large features you dont want in the initial bundle.

Example: dynamic import on user interaction

// assets/js/main-module.js
document.querySelector(#open-settings)?.addEventListener(click, async () =gt {
    try {
        const module = await import(./settings-panel.js)
        // settings-panel.js must export an init function or default
        module.init()
    } catch (err) {
        console.error(Failed to load settings panel, err)
    }
})

You can also use then():

document.querySelector(#open-settings)?.addEventListener(click, () =gt {
    import(./settings-panel.js)
        .then(mod =gt mod.init())
        .catch(err =gt console.error(err))
})

Passing localized data / non-module globals into modules

WordPress normally passes PHP data to JavaScript using wp_localize_script() or wp_add_inline_script(). When using modules you can adopt one of these patterns:

  • Expose data on window (global) and import it from a module-aware loader or read it inside the module (modules can access window).
  • Use a small loader inline module that reads localized globals and passes them to the dynamically imported module.
  • Use the inline module approach if you want to ensure that the dynamic import happens after WordPress has written localized data into window.

Pattern: loader inline module dynamic import (recommended)

Register a tiny module whose only job is to import the main module and pass it localized data. This pattern makes it easy to use wp_localize_script or wp_add_inline_script.

lt?php
function enqueue_module_with_localized_data() {
    handle = my-module-loader
    module_src = get_template_directory_uri() . /assets/js/loader-module.js
    ver = filemtime( get_template_directory() . /assets/js/loader-module.js )
    wp_register_script( handle, module_src, array(), ver, true )
    wp_script_add_data( handle, type, module )

    // Provide data via a global, serialized for JS
    data = array(
        ajaxUrl =gt admin_url( admin-ajax.php ),
        nonce   =gt wp_create_nonce( my_action ),
    )
    wp_localize_script( handle, MY_MODULE_DATA, data )

    wp_enqueue_script( handle )
}
add_action( wp_enqueue_scripts, enqueue_module_with_localized_data )
?gt
// assets/js/loader-module.js
// This file must be served as the module registered above. It is also type=module.
(async () =gt {
    // Access localized data from the global that wp_localize_script created:
    const config = window.MY_MODULE_DATA  {}
    // Dynamically import the real module and pass the config object:
    const main = await import(./main-module.js)
    if (main  typeof main.init === function) {
        main.init(config)
    }
})()

This approach keeps configuration separate from the module code and leverages WPs localization API.

Legacy fallback: using nomodule

To support older browsers that do not understand type=module, serve a transpiled/compiled legacy script with the nomodule attribute. The browser that understands type=module will ignore nomodule scripts. Browsers that dont support modules will use nomodule scripts.

Pattern: register both module and nomodule scripts

lt?php
function enqueue_module_with_nomodule_fallback() {
    module_handle = app-module
    module_src = get_template_directory_uri() . /dist/app.module.js
    wp_register_script( module_handle, module_src, array(), filemtime( get_template_directory() . /dist/app.module.js ), true )
    wp_script_add_data( module_handle, type, module )
    wp_enqueue_script( module_handle )

    // Legacy bundle (transpiled, concatenated)
    legacy_handle = app-legacy
    legacy_src = get_template_directory_uri() . /dist/app.legacy.js
    wp_register_script( legacy_handle, legacy_src, array(), filemtime( get_template_directory() . /dist/app.legacy.js ), true )
    // Add nomodule attribute to the legacy script:
    wp_script_add_data( legacy_handle, nomodule, true )
    wp_enqueue_script( legacy_handle )
}
add_action( wp_enqueue_scripts, enqueue_module_with_nomodule_fallback )
?gt

Note: You must build a legacy bundle (e.g., via Babel bundler) that works without modules. The legacy script should not be as modular it typically exposes a global that your theme reads.

Adding attributes not directly supported by enqueue APIs

To add attributes like crossorigin or a custom integrity attribute, use the script_loader_tag filter to modify the generated script tag. This is also the most reliable way to fine-tune output when combining module and nomodule logic.

lt?php
// Example: add crossorigin attribute and integrity attribute for a specific handle
add_filter( script_loader_tag, function( tag, handle, src ) {
    if ( my-theme-module === handle ) {
        // if WP printed type=module already, we can inject crossorigin/integrity
        crossorigin = anonymous
        integrity = sha384-abc123... // compute during build
        // Insert attributes into the tag string before the closing >
        // tag already looks like: ltscript src=... type=module id=... gtlt/scriptgt
        tag = str_replace( gtlt/scriptgt,  crossorigin= . esc_attr( crossorigin ) .  integrity= . esc_attr( integrity ) . gtlt/scriptgt, tag )
    }
    return tag
}, 10, 3 )
?gt

Be careful with integrity and crossorigin: SRI only works when the served resource is exactly the same bytes. For dynamic files or when using filemtime versioning, ensure the integrity string matches the final file.

Import path rules and CORS

  • Use relative imports (./module.js, ../lib/foo.js) to reference files in the same origin relative URLs resolve relative to the module file.
  • Absolute or origin-based imports (https://cdn.example.com/lib.js) require proper CORS headers from the remote origin and possibly crossorigin attribute on the script tag.
  • Browsers require modules to be served with a JavaScript MIME type. Ensure your server serves .js files with application/javascript or text/javascript.

Build tools and bundlers

If you use bundlers like webpack, Rollup, or Vite, you can still output an ESM build either as multiple ESM chunks or as a single module. If you produce ESM chunks, ensure chunk URLs are reachable and their paths are correct relative to the main module script. Bundlers often provide options to set a publicPath which affects the URLs generated by dynamic imports.

When to bundle vs. ship raw modules

  • Small sites with few modules: shipping raw ESM files may be simplest and requires no bundler.
  • Large apps with many dependencies: use a bundler to optimize, tree-shake, and generate chunked outputs. You can still output module-compatible bundles (E.g., webpack output.module = true).
  • Be aware of how the bundler generates chunk paths configure publicPath properly to match WordPresss URLs.

Common pitfalls and troubleshooting

  • Wrong import path: Relative imports resolve to the module files location, not the page. Use correct pathing.
  • Not adding type=module: The file will be executed as classic script and import/export will error.
  • MIME types: Server must serve .js with a JavaScript MIME type.
  • CORS errors: When loading modules from a different origin, ensure CORS headers and crossorigin attribute are used.
  • Localize script/order issues: If wp_localize_script places globals after your module has executed, use an inline module loader to sequence correctly.
  • Legacy browsers: Without nomodule fallback, older browsers will not be able to run module-only code.

Advanced examples

1) Minimal complete example: module dynamic import

lt?php
// functions.php
function enqueue_minimal_module() {
    handle = minimal-module
    src = get_template_directory_uri() . /assets/js/app.js
    wp_register_script( handle, src, array(), filemtime( get_template_directory() . /assets/js/app.js ), true )
    wp_script_add_data( handle, type, module )
    wp_enqueue_script( handle )
}
add_action( wp_enqueue_scripts, enqueue_minimal_module )
?gt
// assets/js/app.js
console.log(App module loaded)

document.querySelector(#load-chart)?.addEventListener(click, async () =gt {
    const chartMod = await import(./charts/chart.js)
    chartMod.renderChart(#chart-root, { / options / })
})

2) Inline module loader that passes PHP data

lt?php
function enqueue_loader_and_localize() {
    handle = app-loader
    src = get_template_directory_uri() . /assets/js/loader.js
    wp_register_script( handle, src, array(), filemtime( get_template_directory() . /assets/js/loader.js ), true )
    wp_script_add_data( handle, type, module )
    wp_localize_script( handle, APP_CONFIG, array(
        restUrl =gt esc_url_raw( rest_url() ),
        nonce   =gt wp_create_nonce( wp_rest ),
    ) )
    wp_enqueue_script( handle )
}
add_action( wp_enqueue_scripts, enqueue_loader_and_localize )
?gt
// assets/js/loader.js
(async () =gt {
    const cfg = window.APP_CONFIG  {}
    const app = await import(./app-main.js)
    app.start(cfg)
})()

3) Filtering script tag to add crossorigin amp SRI

lt?php
add_filter( script_loader_tag, function( tag, handle, src ) {
    if ( cdn-module === handle ) {
        // Add crossorigin and integrity for a module loaded from CDN
        cross = anonymous
        sri = sha384-... // put real SRI here
        // Ensure type=module is present if not, add it
        if ( false === strpos( tag, type= ) ) {
            tag = str_replace( gtlt/scriptgt,  type=module crossorigin= . esc_attr( cross ) .  integrity= . esc_attr( sri ) . gtlt/scriptgt, tag )
        } else {
            tag = str_replace( gtlt/scriptgt,  crossorigin= . esc_attr( cross ) .  integrity= . esc_attr( sri ) . gtlt/scriptgt, tag )
        }
    }
    return tag
}, 10, 3 )
?gt

Performance and caching considerations

  • Module scripts are deferred, so they dont block initial HTML parsing — good for perceived performance.
  • Use proper cache headers (long TTL file-hash or filemtime versioning) so browsers can cache module files efficiently.
  • When using dynamic import to create small chunks, ensure your server can serve many small files without excessive overhead. HTTP/2 or HTTP/3 helps with many small resources.

Security notes

  • Serve static assets over HTTPS.
  • If you use Subresource Integrity (SRI) with crossorigin, ensure the crossorigin attribute matches the origin’s CORS policy.
  • Sanitize any data passed from PHP to JS (via wp_localize_script) and treat it as untrusted unless validated.

Examples summary checklist

  1. Register module script: wp_register_script() wp_script_add_data(handle, type, module).
  2. Use import/export in module files for static loading where possible.
  3. Use dynamic import() for lazy-loading features and on-demand chunks.
  4. Use loader inline module wp_localize_script to safely pass PHP data to a module before importing the main module.
  5. Provide a nomodule legacy bundle for older browsers and mark it with wp_script_add_data(handle, nomodule, true).
  6. Use script_loader_tag filter to add crossorigin/integrity or other attributes not directly supported by the enqueue API.
  7. Mind MIME types, CORS, and correct import paths (relative to module file).

Further reading

For authoritative references consult:

Conclusion

Enqueuing ES module scripts and using dynamic import in WordPress provides modern, performant patterns for delivering JavaScript. Use wp_script_add_data(…, type, module) to mark a script as a module. Prefer dynamic import() inside modules for lazy-loading. Provide a nomodule fallback for legacy browsers. Use small inline module loaders and wp_localize_script to transfer PHP data reliably to modules. Handle extra attributes and CORS via the script_loader_tag filter. Follow best practices for caching, MIME types, and build configuration to ensure smooth behavior across environments.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *