Dropping a raw <script> tag into a WordPress template works — until it doesn’t. jQuery version conflicts, duplicate loads, render-blocking, and unmanageable plugin dependencies are all downstream consequences of bypassing the enqueue system. WordPress provides wp_enqueue_script() specifically to prevent these problems, and using it correctly is the difference between a maintainable codebase and one that breaks silently.
Why Raw <script> Tags Break WordPress
The issues are predictable once you know the enqueue system exists:
<!-- This is wrong — never do this in a WordPress theme or plugin -->
<script src="https://cdn.example.com/some-library.min.js"></script>
<script>
// Your code here
</script>
- jQuery conflicts. WordPress ships its own jQuery in no-conflict mode. Manually loading a second jQuery from a CDN breaks every plugin that relies on the one WP manages.
- Duplicate loads. Two plugins may register the same CDN library independently. The enqueue system deduplicates by handle; raw tags don’t.
- Unordered dependencies. A slider script that assumes jQuery is loaded first will silently fail if both tags land in the wrong order.
- Render-blocking. Undeferred scripts in
<head>block HTML parsing and hurt Core Web Vitals. The enqueue API’sstrategy: deferhandles this cleanly in WP 6.3+.
For a full walkthrough of the wp_enqueue_script() parameter list, WP 6.3 strategy options, and admin_enqueue_scripts, see the WordPress enqueue scripts guide. This post focuses on the external library integration patterns: CDN dependencies, SRI, passing PHP data to JS, and removing conflicting scripts.
Register vs Enqueue: When Each Is Correct
wp_register_script() and wp_enqueue_script() are often confused. Register declares a script and makes its handle available for dependency resolution — but does not output a <script> tag. Enqueue does both.
// wp_register_script() — declare the script without loading it yet.
// Use this in a plugin that provides a library others might depend on.
wp_register_script(
'my-chart-library',
get_template_directory_uri() . '/assets/js/chart.umd.min.js',
array(),
'4.4.1',
array( 'in_footer' => true )
);
// wp_enqueue_script() — declare AND load in one call.
// Use this when you know the script is always needed on this page.
wp_enqueue_script(
'my-chart-library',
get_template_directory_uri() . '/assets/js/chart.umd.min.js',
array(),
'4.4.1',
array( 'in_footer' => true )
);
// After registering, any code can enqueue by handle alone — no URL needed.
wp_enqueue_script( 'my-chart-library' );
Use wp_register_script() in a plugin that provides a shared library — other plugins and themes can then declare a dependency on its handle without specifying a URL, and WordPress resolves the load order automatically. Use wp_enqueue_script() directly when you own both the library and its load decision.
Loading a Local Script with WP 6.3 Defer Strategy
The old pattern for deferring a script was to hook script_loader_tag and manually inject the defer attribute — a fragile string manipulation. WordPress 6.3 replaced that with a first-class strategy key in the $args array.
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_assets' );
function mytheme_enqueue_assets(): void {
wp_enqueue_script(
'mytheme-animations',
get_template_directory_uri() . '/assets/js/animations.js',
array(), // no dependencies
filemtime( get_template_directory() . '/assets/js/animations.js' ), // auto cache-bust
array(
'strategy' => 'defer', // WP 6.3+ — replaces script_loader_tag hack
'in_footer' => true,
)
);
}
strategy: 'defer' outputs <script defer> on the tag. WordPress also handles the dependency chain: if script A depends on script B and A is deferred, WordPress defers B too — no manual coordination needed.
Integrating a CDN Library
CDN-hosted libraries should always go through wp_register_script() so that multiple parts of the theme or plugin ecosystem can depend on them without double-loading. Registering with the CDN URL means WordPress handles SemVer in the query string, but the actual file is served from the fast CDN edge.
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_swiper' );
function mytheme_enqueue_swiper(): void {
// Register the CDN library so plugins/child themes can depend on it by handle.
wp_register_script(
'swiper',
'https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js',
array(),
'11.0.5',
array( 'strategy' => 'defer', 'in_footer' => true )
);
// Enqueue your initialisation script that depends on Swiper.
wp_enqueue_script(
'mytheme-hero-slider',
get_template_directory_uri() . '/assets/js/hero-slider.js',
array( 'swiper' ), // WordPress loads swiper first automatically
filemtime( get_template_directory() . '/assets/js/hero-slider.js' ),
array( 'strategy' => 'defer', 'in_footer' => true )
);
}
Adding Subresource Integrity (SRI) to CDN Scripts
SRI locks a CDN script to a specific file hash. If the CDN ever serves a modified file (through compromise or misconfiguration), the browser blocks it. The script_loader_tag filter is still the right tool here — not as a workaround for missing features, but as the intended hook for adding non-standard HTML attributes to a script tag.
// Add SRI integrity and crossorigin attributes via script_loader_tag.
// Use https://www.srihash.org/ to generate the hash for any CDN URL.
add_filter( 'script_loader_tag', 'mytheme_add_sri_to_cdn_scripts', 10, 3 );
function mytheme_add_sri_to_cdn_scripts( string $tag, string $handle, string $src ): string {
$sri_map = array(
'swiper' => 'sha384-wEmeIV1mKuiNpC+IOBjI7aAzPcEZeedi5yW5f2yOq55WWLwNGmvvx4Um1vskeMj0',
);
if ( ! isset( $sri_map[ $handle ] ) ) {
return $tag;
}
return str_replace(
'<script ',
'<script integrity="' . esc_attr( $sri_map[ $handle ] ) . '" crossorigin="anonymous" ',
$tag
);
}
Generate the hash at srihash.org or via the openssl dgst -sha384 -binary < file | openssl base64 -A command. Use the same hash you’d paste into a raw <script integrity="..."> tag.
Conditional Loading: Only Where Needed
Loading a script on every page when it’s only needed on one is a measurable performance cost. WordPress conditional tags let you gate enqueues precisely.
add_action( 'wp_enqueue_scripts', 'mytheme_conditional_scripts' );
function mytheme_conditional_scripts(): void {
// Contact page only.
if ( is_page( 'contact' ) ) {
wp_enqueue_script(
'mytheme-recaptcha',
'https://www.google.com/recaptcha/api.js',
array(),
null, // let Google manage its own version
array( 'in_footer' => false ) // reCAPTCHA needs to be in <head>
);
}
// All single posts.
if ( is_singular( 'post' ) ) {
wp_enqueue_script(
'mytheme-share-buttons',
get_template_directory_uri() . '/assets/js/share.js',
array(),
filemtime( get_template_directory() . '/assets/js/share.js' ),
array( 'strategy' => 'defer', 'in_footer' => true )
);
}
}
For performance impact at the page level, see the WordPress performance guide and the Core Web Vitals optimization guide — both cover script weight as a contributor to LCP and TBT.
Passing PHP Data to JavaScript
Dynamic data (a nonce, a post ID, an API URL) belongs in the enqueue pipeline — not hardcoded into a <script> block in the template. Two functions handle this, and they’re not interchangeable.
wp_localize_script() — Named Global Object
wp_localize_script() outputs a <script> block immediately before the enqueued script that assigns the data to a named global variable. It is the right choice when you need a stable JS namespace your scripts reference by name.
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_ajax_script' );
function mytheme_enqueue_ajax_script(): void {
wp_enqueue_script(
'mytheme-ajax',
get_template_directory_uri() . '/assets/js/ajax-handler.js',
array(),
filemtime( get_template_directory() . '/assets/js/ajax-handler.js' ),
array( 'strategy' => 'defer', 'in_footer' => true )
);
// wp_localize_script — passes an object; best for a named config blob.
wp_localize_script( 'mytheme-ajax', 'MythemeConfig', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mytheme_ajax' ),
'postId' => get_the_ID(),
) );
}
In ajax-handler.js, access the object as MythemeConfig.ajaxUrl, MythemeConfig.nonce, etc. Always pass nonces through this mechanism, never hardcoded — this is what makes them per-session and CSRF-safe. See the sanitization vs validation guide for what to do with the data on the PHP side when the AJAX request comes back.
wp_add_inline_script() — Arbitrary Inline Snippet
wp_add_inline_script() is more flexible: it attaches any JS snippet before or after the enqueued script, without the named-global-object constraint. Use it when you’re configuring a third-party library whose initialisation pattern expects a plain variable, not a namespaced config object.
// wp_add_inline_script() — attach a small JS snippet before or after an enqueued script.
// Cleaner than localize for simple data or when you don't need a named global object.
add_action( 'wp_enqueue_scripts', 'mytheme_inline_config' );
function mytheme_inline_config(): void {
wp_enqueue_script( 'mytheme-map', get_template_directory_uri() . '/assets/js/map.js',
array(), filemtime( get_template_directory() . '/assets/js/map.js' ),
array( 'in_footer' => true ) );
// Output before the script tag so the config is available at module init.
wp_add_inline_script(
'mytheme-map',
'const mapConfig = ' . wp_json_encode( array(
'apiKey' => get_option( 'mytheme_maps_api_key' ),
'center' => array( 'lat' => 51.5, 'lng' => -0.12 ),
) ) . ';',
'before'
);
}
The 'before' position ensures mapConfig is defined before map.js runs. Use 'after' to run initialisation code that calls functions the library exports.
Removing Scripts a Plugin Loads Unnecessarily
Contact Form 7, WooCommerce, and similar plugins load scripts and styles globally even on pages that don’t use them. Dequeue at priority 99 (higher than the plugin’s own enqueue) to remove them from specific pages.
// Remove a script a plugin is loading on every page.
// Hook at priority 99 so this runs after the plugin's enqueue (usually priority 10).
add_action( 'wp_enqueue_scripts', 'mytheme_remove_plugin_scripts', 99 );
function mytheme_remove_plugin_scripts(): void {
// Dequeue removes the script from the output.
// Deregister also removes its registration so nothing else can re-enqueue it.
if ( ! is_page( 'contact' ) ) {
wp_dequeue_script( 'contact-form-7' );
wp_deregister_script( 'contact-form-7' );
}
}
Find a script’s handle by inspecting the page source — WordPress outputs it as the id attribute on the script tag (id="contact-form-7-js" → handle is contact-form-7). Deregistering as well as dequeueing prevents the script from being re-enqueued later by another hook.
ES Module Scripts
WordPress does not yet natively support type="module" via the $args array in wp_enqueue_script(). Until it does, the script_loader_tag filter is the correct approach — scoped tightly to the specific handle so it doesn’t affect any other scripts.
// WordPress does not natively support type="module" via wp_enqueue_script $args yet.
// Use the script_loader_tag filter to add the attribute for a specific handle.
add_filter( 'script_loader_tag', 'mytheme_add_module_type', 10, 3 );
function mytheme_add_module_type( string $tag, string $handle, string $src ): string {
if ( 'mytheme-esm-component' !== $handle ) {
return $tag;
}
// Replace type="text/javascript" with type="module".
return str_replace( "type='text/javascript'", "type='module'", $tag );
}
// Enqueue the script normally — the filter handles the type attribute.
wp_enqueue_script(
'mytheme-esm-component',
get_template_directory_uri() . '/assets/js/component.mjs',
array(),
filemtime( get_template_directory() . '/assets/js/component.mjs' ),
array( 'in_footer' => true )
);
Note that type="module" scripts are always deferred by the browser regardless of the defer attribute, so the performance behaviour is the same as strategy: 'defer'. They also execute in strict mode and have their own scope by default — no global leakage. For REST-driven data in module scripts, see the custom REST API endpoints guide for the authentication and fetch patterns.
Quick-Reference Checklist
- Never add raw
<script>tags to templates — always usewp_enqueue_script(). - Use
wp_register_script()in plugins that provide shared libraries; enqueue in code that consumes them. - Use
strategy: 'defer'orstrategy: 'async'in the$argsarray (WP 6.3+) — not thescript_loader_taghack. - Add SRI
integrityattributes to CDN scripts viascript_loader_tag. - Gate enqueues with conditional tags (
is_page(),is_singular()) — load scripts only where they’re used. - Pass PHP data to scripts via
wp_localize_script()(named config object) orwp_add_inline_script()(arbitrary snippet). - Dequeue plugin scripts at priority 99; deregister to prevent re-enqueueing downstream.
- For
type="module"scripts, use thescript_loader_tagfilter on the specific handle.


