4 new habits for adding stylesheets and scripts to WordPress

By Ian Svoboda on October 22, 2025

WordPress has some great new features and development patterns to help you get the most out of the stylesheets and scripts you’re adding to your pages. In this post I’ll show you 4 new habits to simplify enqueueing styles and scripts on your WordPress site.

Let’s start with a traditional example. If I had child theme and I wanted to add a stylesheet and script to all frontend pages, that code might look like this:

<?php

add_action( 'wp_enqueue_scripts', function() {
  // Add a new stylesheet with the handle child-styles.
  wp_enqueue_style( 'child-styles', get_stylesheet_uri() );
  
  // Add a script from the child theme in the footer and after jQuery.
  wp_enqueue_script( 
    'child-scripts',
    get_stylesheet_directory_uri() . '/scripts.js',
    [ 'jquery' ],
    '',
    true
  );
} );

If you’ve seen WordPress sites or docs you’ve probably seen something like this before. Let’s walk through a few simple ways we can update this using some more modern best practices and then explore a few more new techniques.

1. Themes: Use get_theme_file_uri and get_theme_file_path

In the first example above, the PHP function get_stylesheet_directory_uri is used to build the URI string (i.e. the web address to the file) for scripts.js using concatenation. This is fine, but it’s a little verbose and you have to remember to include the leading forward slash, etc.

There’s a better alternative that’s available for themes: get_theme_file_uri. Here’s how the above example would look with that instead:

<?php

add_action( 'wp_enqueue_scripts', function() {
  // Add a new stylesheet with the handle child-styles.
  wp_enqueue_style( 'child-styles', get_stylesheet_uri() );
  
  // Add a script from the child theme in the footer and after jQuery.
  wp_enqueue_script( 
    'child-scripts',
    get_theme_file_uri( 'scripts.js' ), 
    [ 'jquery' ],
    '',
    true
  );
} );

This function allows you to use a relative path with or without a leading slash that checks the base of the child theme and the parent theme if one is in use. It’s much more forgiving and intuitive.

The function get_stylesheet_directory_uri will specifically only check the active theme. So if your active theme is a child theme, it wouldn’t double check the parent theme to see if it had a scripts.js file to include also and you’d need to use get_template_directory_uri as well.

When you use get_theme_file_uri you don’t have to keep track of which theme you’re checking. This is especially relevant if you’re building a theme that allows someone to override the assets in some way (like use their own stylesheet in place of the built in one).

Similarly, if you need to get the path for a PHP file (which is not the same as a URI, which is a web address) you can use get_theme_file_path instead of get_stylesheet_directory and get_template_directory and get the same benefits of automatic fallback.

Old Habit

Building URIs and paths to theme files using string concatenation get_stylesheet_* and get_template_* functions.

New Habit

Use get_theme_file_uri and get_theme_file_path instead to easily reference the root of the active theme with optional parent theme fallback.

2. Dynamic Versions and Dependencies

Whenever you enqueue stylesheets or scripts in WordPress you can optionally specify a version. This is a value that is appended to the URL when the file is added to the page markup that helps the browser or cache plugins understand when the file has changed and “bust” the cache to get the new version of the file.

If you set the value to false or an empty string, the WordPress version is used. You may also use the plugin or theme version the file is coming from. But when you’re doing work on a local install or a staging environment, you might want to see those updates right away for testing purposes. If your browser/server doesn’t see the URL change, it may cache those changes depending on how the site is set up.

There are 2 different ways you can achieve this sort of thing:

  1. Checking the SCRIPT_DEBUG constant and if enabled, using the php time function to generate a version for the asset.
  2. Use a dynamic version number from webpack or a similar build process.

Checking SCRIPT_DEBUG

If you’re not using any sort of bundler like webpack, vite, esbuild, etc to build your source code then you need to do this yourself. Here’s a simple pattern for a dynamic asset version using only PHP:

<?php

add_action( 'wp_enqueue_scripts', function() {
  // Add a new stylesheet with the handle child-styles.
  wp_enqueue_style( 'child-styles', get_stylesheet_uri() );
  
  
  // Use the SCRIPT_DEBUG constant to determine if it's "prod mode" or not.
  $prod = !( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ); 
  
  // Your default version. If false, this will use the current WP Version.
  $default_version = 1.2.3; 
  
  wp_enqueue_script( 
    'child-scripts',
    get_theme_file_uri( 'scripts.js' ), 
    [ 'jquery' ],
    $prod ? $default_version : time(), // Add the version param value to wp_enqueue_script.
    true
  );
} );


?>

// Rendered code
<script src="https://www.yoursite.com/wp-content/themes/your-theme/scripts.js?ver=1022122350" id="child-scripts-js"></script>

Here we check if we should use the dynamic version based on the SCRIPT_DEBUG constant from WordPress core. You can set this value to true during development to use the unminified version of scripts and checking it in this way is actually a pattern in WordPress itself.

So the idea there is that if you’re doing local work, or working on a staging environment, SCRIPT_DEBUG should be set to true, which then makes the version of the enqueue unique every time the page loads. Why? Because the time function gets the current server time and displays it. This ensures the version is always unique and it doesn’t require you to do i/o (disk) operations to get the value.

By adding the ?ver= param to the script src value, it makes that particular link unique. When it changes, the browser goes “oh yeah that’s not the same link anymore” and then re-downloads the file.

You may have previously seen filemtime used for this purpose and sometimes I still do use it. But filemtime involves a disk (i/o) operation which on a busy site can be bad for performance. The results are also cached so in some limited situations it may not always work as expected anyway. The above approach is a more reliable option which is why core started using it instead of filemtime (see the previous link above).

Using a dynamic version number from webpack using @wordpress/scripts

The easiest way is to use @wordpress/scripts, 10up-toolkit, or similar to generate the files needed as this is already set up. Alternatively, you can implement the @wordpress/dependency-extraction-webpack-plugin package directly in your webpack config in as little as one line:

// webpack.config.js
const DependencyExtractionWebpackPlugin = require( 'webpack-dependency-extraction-webpack-plugin' );

module.exports {
  // ... your config
  plugins: [
    // ... your plugins
    new DependencyExtractionWebpackPlugin()
  ]
};

There are other options you can pass to the plugin which read more about on NPM.

This plugin does a few really important things:

  • It externalizes certain @wordpress/* npm packages which keeps your file size smaller
  • It generates an asset.php file for each entry which includes it’s dependencies and a version.

So let’s say you had a theme where you were building your JavaScript, SCSS, etc in a src folder and then it was set up to place the built files in a build folder. When those are built, the JS files will get an asset file generated that’s the file’s basename with .asset.php at the end, like so:

-- your-theme
  -- /src
    -- /scss
    -- /js
      -- scripts.js
  -- /build
    -- /js
      -- scripts.js
      -- scripts.asset.php

That asset file looks something like this:

<?php return array('dependencies' => array('wp-block-editor', 'wp-blocks', 'wp-data', 'wp-element', 'wp-rich-text'), 'version' => '8a7e3e949bdc1790cbe6');

One interesting note here: there is a return outside of a function.

Interesting

This is a handy pattern because it allows you to use an include or require statement to get the value of that return (i.e. the array with the ‘dependencies’ and ‘version’). We can then use that returned value to get the version (and dependencies) dynamically.

<?php

/**
 * Get asset info from extracted asset files
 *
 * @param string $slug Asset slug as defined in build/webpack configuration
 * @param string $attribute Optional attribute to get. Can be version or dependencies
 * @return string|array
 */
function get_asset_info( $slug, $attribute = null ) {
	$js_asset_path  = get_theme_file_path( "build/js/$slug.asset.php" );
	$css_asset_path = get_theme_file_path( "build/css/$slug.asset.php" );

	$asset_path = file_exists( $js_asset_path )
		? $js_asset_path
		: file_exists( $css_asset_path )
			? $css_asset_path
			: null;
	$asset = $asset_path ? require $asset_path : null;

	if ( ! empty( $attribute ) && isset( $asset[ $attribute ] ) ) {
		return $asset[ $attribute ];
	}

	return $asset;
}


add_action( 'wp_enqueue_scripts', function() {  
  $frontend_asset_info = get_asset_info( 'frontend' );
  
  wp_enqueue_script( 
    'frontend',
    get_theme_file_uri( 'build/js/frontend.js' ), 
    $frontend_asset_info['dependencies'],
    $frontend_asset_info['version'], // Add the version param value to wp_enqueue_script.
    true
  );
} );

Now this script will always have a dynamic version and updated dependencies automatically without any need to check for SCRIPT_DEBUG or similar and zero performance impact to the server.

Now let’s talk a little bit more about the dependencies and why getting them dynamically can be so important.

About Dependency Extraction

When you’re working with React and the block/site editor, you’re almost definitely going to be importing packages into your code. These might be React hooks like useState, WordPress functions like apiFetch, or any other things you might be using. When you import functions into your files, the definition of those functions has to be in the file with it so you can call that function.

So imagine you have several custom blocks and each one has it’s own custom controls, so you’re importing all kinds of stuff like TextControl, Button, ComboboxControl, ToggleControl and so on for each of these:

import {
  TextControl,
  Button,
  ComboboxControl,
  ToggleControl
} from '@wordpress/components';

This means that the definition of those React components along with all of the code they import and use needs to now be included or available to this file. So if you have several different files that are all using those same components, the code for those things is duplicated in each of the bundles.

This effectively makes the size of your files much larger and your site (and the editor) will be slower. And nobody likes things bigger and slower!

So the Gutenberg team came up with this concept for Dependency Extraction, where they have webpack look for imports to a few select packages that are part of their @wordpress/* organization, including @wordpress/components. So when webpack goes to build the file, it doesn’t actually include all of that code in your bundle and instead tells your JavaScript to look at the global wp.components (depending on which package is being externalized).

This allows WordPress itself to load one version of all of those different things and your custom code is able to just use those scripts that are already there. This amounts to way smaller file sizes and better performance for users.

We’ve talked about how @wordpress/scripts generates an asset.php file for your entries (i.e. each of the files it’s building for you). In addition to a version, that file includes a dynamic list of string handles to the registered scripts of those externalized dependencies. This allows you to “set it and forget it” and just have the right dependencies based on what you’ve included in your files.

The best part of this approach is that you can just set it once and never have to worry about it (or which environment you’re using).

Old Habit

  • Not specify a version for enqueues or just using the theme/plugin version.
  • Not using a dynamic enqueue version during testing or staging
  • Including duplicate code in JavaScript bundles that WordPress already provides

New Habit

  • If you’re not building your JavaScript, use a dynamic asset version pattern to see the latest changes to your files while you’re testing or working locally.
  • If you’re building your JavaScript and stylesheets, consider using @wordpress/dependency-extraction-webpack-plugin or just use @wordpress/scripts to build your project files.
  • Use webpack (or similar tools) to generate an asset file that has a dynamic version and use that in your enqueues.
  • Externalize dependencies automatically with the webpack plugin and use that dynamic list in your enqueues

3. Ensure block-specific styles and scripts only load when the block is on the page

If you have a stylesheet or script that should only be included once if a given block is being used on the page, you should be including that as a style or script in the block’s block.json file. This will automatically handle the conditional enqueueing for you and makes it really simple to avoid including extra things on a page that doesn’t need it.

Consider a basic example block.json file

// block.json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "lwptd/example-block",
	"version": "0.1.0",
	"title": "Example Block",
	"attributes": {
  	// ...
	},
	"editorScript": "file:./index.js"
}

Let’s say this block has a file called frontend.js that only should be loaded on the frontend of the site when the block is actually on the page. That file is probably located inside the folder of the custom block itself:

/example-block
  -- block.json
  -- index.js
  -- edit.js
  -- save.js
  -- frontend.js

I can just add a single key to block.json with the path to that file and that’s all it takes:

// block.json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "lwptd/example-block",
	"version": "0.1.0",
	"title": "Example Block",
	"attributes": {
  	// ...
	},
	"editorScript": "file:./index.js",
	"viewScript": "file:./frontend.js"
}

Let’s say that you had registered a script previously that wasn’t explicitly part of this block that you also wanted to include. You could do that by changing that string to an array, and adding another value that is the handle of the previously registered script:

// block.json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "lwptd/example-block",
	"version": "0.1.0",
	"title": "Example Block",
	"attributes": {
  	// ...
	},
	"editorScript": "file:./index.js",
	"viewScript": [ "file:./frontend.js", "registered-script" ]
}

There are a few other keys you can use here:

  • script Scripts that should load in the frontend and editor
  • style Stylesheets that should load in the frontend and editor
  • viewStyle Stylesheets that should only load on the frontend
  • editorStyle Stylesheets that should only load in the editor

You can check out the documentation on block.json for additional details.

Old Habit

Enqueueing block-specific stylesheets and scripts on all pages even if the block isn’t on the page.

New Habit

Use the script/style keys in block.json to include scripts and stylesheets in the right place only when the block is being used.

4. Deferring non-critical scripts

If you’re doing performance testing and trying to meet Core Web Vitals standards, you may have heard of “deferring scripts” to improve performance. By default, when the browser is loading a JavaScript file from a <script> tag it will wait to finish loading and parsing it before moving on to the rest of the page. This is referred to as “blocking” or “synchronous loading” because the script’s loading and execution may “block” the page from loading more.

Determining exactly what scripts should be async or not isn’t a cut-and-dry process. You may run performance tests that tell you every script should be async and that is obviously not necessary either.

The idea is to look for non-critical scripts that don’t need to do anything immediately when the page loads. Things like animation libraries, tracking scripts, and other non-critical site scripts are usually a good candidate to load async.

That said, make sure you’re testing the script after changing the loading strategy to ensure it still works as expected!

Up until recently, making a script load async or deferred was possible, not super easy to do like regular enqueues are. Now you can just change the last argument in wp_enqueue_script to use the new $args pattern like so:

<?php
add_action( 'wp_enqueue_scripts', function() {  
  $frontend_asset_info = get_asset_info( 'frontend' );
  
  wp_enqueue_script( 
    'frontend',
    get_theme_file_uri( 'build/js/frontend.js' ), 
    $frontend_asset_info['dependencies'],
    $frontend_asset_info['version'], // Add the version param value to wp_enqueue_script.
    [
      'strategy'  => 'defer',
      'in_footer' => true
    ]
  );
} );

So instead of a boolean, we’re specifying an args array where we can provide a loading strategy (async or defer) and then still indicate if the script should load in the footer or not.

Typically for best performance it’s best to load in the footer and defer the script, but you need to test things for your individual use case to determine exactly where (and with what strategy) you’re loading your scripts.

Old Habit

Load all scripts synchronously or use a custom filter to handle async/defer loading.

New Habit

Use the new $args list format for wp_enqueue_script for async/defer loading on scripts that need it. Test thoroughly!

Further Reading



2 responses to “4 new habits for adding stylesheets and scripts to WordPress”

  1. Weston Ruter Avatar

    Re: Deferring non-critical scripts

    It would generally be better to use defer instead of async when you want to prevent a script from blocking a page from rendering. This is because a script with async is eligible to execute in a blocking way if the script is in cache or even if the script loads before the HTML has finished loading. For this reason, it’s better and more predictable to use defer since the script will always execute after the DOM has loaded and always in the same order.

    Something else to consider is to use script modules instead of classic scripts. They always load in a deferred way, and they have other performance benefits. This also goes for inline scripts, since a classic inline script is also render blocking (although in a way that isn’t as bad as an external script). WordPress 6.9 switches to using a script module for the emoji loader, and it outputs the emoji settings in a JSON script to prevent it from blocking rendering. See:
    https://core.trac.wordpress.org/ticket/58472
    https://core.trac.wordpress.org/ticket/64076

    Also, in WordPress 6.9 we’re getting few deprioritization powers! Along with the strategy and in_footer keys, you will also be able to add fetchpriority to the $args. You can specify fetchpriority=low to help prevent the script’s loading from competing with critical resources like the LCP image. I did a write up about this: https://weston.ruter.net/2025/05/26/improve-lcp-by-deprioritizing-interactivity-api-script-modules/

    1. Ian Svoboda Avatar

      Thanks Weston this is some great info!

Leave a Reply

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