Creating a Custom Drupal Module With Webpack Bundling
A custom Drupal module is often the cleanest way to add a focused feature without placing application logic in a theme or editing contributed code. When that feature includes JavaScript, CSS, or modern front-end dependencies, a bundler can make the module easier to develop, test, and deploy.
Webpack is useful when a Drupal project needs import statements, npm packages, code splitting, minification, or separate development and production builds. Drupal still controls how assets are declared and attached, while Webpack prepares the source files that the browser receives.
The key is to keep the responsibilities clear. Drupal defines the module, routes, permissions, render arrays, and asset libraries. Webpack resolves JavaScript dependencies and produces browser-ready files. This separation works well for a small editorial tool in Melbourne, a customer portal in Sydney, or a larger site hosted in an Australian cloud region.
Australian projects also need to consider practical operational details. A team may work across Australian Eastern, Central, and Western time zones, while a public website must handle local privacy expectations under the Privacy Act 1988 and its Australian Privacy Principles. A reliable build process helps developers make those changes consistently rather than copying files manually between environments.
| Approach | Best suited to | Main trade-off |
|---|---|---|
| Drupal library with plain JavaScript | Small behaviours and progressive enhancement | Limited dependency management |
| Webpack bundle in a custom module | Components using npm packages or modern syntax | Requires a Node-based build step |
| Vite or another modern bundler | Fast front-end development and newer workflows | May need extra Drupal integration decisions |
| Theme-only assets | Presentation-specific interactions | Reuse and feature ownership can become unclear |
Create The Module Foundation
Begin with a module directory inside the project, such as web/modules/custom/stock_alert. The machine name should use lowercase letters, numbers, and underscores, and it should describe the feature rather than the implementation. A basic custom module can start with an information file and a libraries definition:
stock_alert/
├── stock_alert.info.yml
├── stock_alert.libraries.yml
├── js/
├── css/
├── src/
└── package.json
The .info.yml file tells Drupal that the extension exists and which core version it supports:
name: Stock Alert
type: module
description: Adds stock notifications to product pages.
package: Custom
core_version_requirement: ^10 || ^11
Enable it with Drush or the administration interface. For a development site, drush en stock_alert -y is convenient, followed by drush cr when changing discovery-related files. A module can contain JavaScript without a controller, but many useful features eventually need a route, a plugin, a field formatter, or a preprocess function that attaches the library only where it is needed.
Use a library definition to describe the compiled assets Drupal should load:
stock-alert:
version: 1.x
js:
dist/stock-alert.js: { minified: true }
css:
theme:
dist/stock-alert.css: {}
dependencies:
- core/drupal
- core/once
The core/once dependency matters when behaviours can be attached repeatedly after an AJAX request. Drupal’s behaviour system may run more than once on a page, so ordinary document-ready code can create duplicate event handlers. A module should use Drupal.behaviors and once() instead.
Configure Webpack For Drupal Assets
Place package.json in the module directory so the JavaScript dependencies belong to the feature. A minimal configuration may include Webpack, its command-line interface, Babel, and a CSS loader:
{
"name": "stock-alert",
"private": true,
"scripts": {
"build": "webpack --mode production",
"watch": "webpack --mode development --watch"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"babel-loader": "^9.1.3",
"@babel/preset-env": "^7.24.0",
"css-loader": "^6.11.0",
"mini-css-extract-plugin": "^2.9.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
}
}
The exact versions will change, so commit the generated lock file and install dependencies with npm ci in automated environments. Developers working from Brisbane, Perth, or Adelaide should use the project’s documented Node version rather than relying on whatever happens to be installed locally. A version manager such as Volta or nvm helps the whole team use the same toolchain.
An example webpack.config.js imports source from src/index.js and writes the bundle to Drupal’s dist directory:
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'stock-alert.js',
path: path.resolve(__dirname, 'dist'),
clean: true
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
},
devtool: 'source-map'
};
For a simple module, the bundle can remain a single file. If the feature grows, add separate entries or dynamic imports, then describe every generated file in the Drupal library. Avoid placing node_modules in the web root or committing it to Git. The compiled dist files should either be built during deployment or committed according to the project’s release policy.
Attach Behaviours The Drupal Way
The source entry point should use Drupal’s behaviour API. A basic implementation might look like this:
(function (Drupal, once) {
Drupal.behaviors.stockAlert = {
attach(context) {
once('stock-alert', '[data-stock-alert]', context).forEach((element) => {
element.addEventListener('click', () => {
element.classList.toggle('is-active');
});
});
}
};
})(Drupal, once);
Webpack can bundle this file, but it does not automatically make the resulting script available on a Drupal page. Attach the library in a render array, Twig template, controller response, or preprocess hook:
$build['#attached']['library'][] = 'stock_alert/stock-alert';
For a block or field formatter, attaching the library alongside the markup keeps asset loading targeted. Attaching it globally from a page template is easier at first, but it can add unnecessary requests and JavaScript execution to every page. On a content-heavy Australian website, where mobile users may browse over variable connections outside major cities, loading only the required asset is a worthwhile performance decision.
Keep data exchange explicit. Drupal can render configuration into data-* attributes, pass values through drupalSettings, or expose a JSON endpoint with appropriate access controls. Do not place private customer information in markup merely because a script needs a value. A feature dealing with names, email addresses, or order details should be reviewed against the Privacy Act and the organisation’s privacy policy.
If the module displays a form or receives user input, server-side validation remains essential. Client-side JavaScript improves usability but cannot enforce permissions or protect an endpoint. For spam-sensitive forms, a useful companion is this Honeypot checklist, particularly when a public Drupal site receives automated submissions.
Make Development And Builds Predictable
A bundler is most valuable when its workflow is repeatable. Keep source files in src, generated assets in dist, and configuration in a clearly named file. Add a .gitignore that excludes node_modules, temporary logs, and editor-specific files. If dist is generated in CI, the deployment job must run the build before Drupal’s release becomes active.
Useful checks should run before a merge:
- Install exact dependencies with
npm ci. - Build the production bundle with
npm run build. - Inspect the generated files and source maps.
- Clear Drupal caches on the test environment.
Development and production should use different settings where appropriate. A development build can retain source maps and readable output, while production should minify JavaScript and remove debugging code. Source maps need care on a public site because they can reveal source structure or comments that the project does not intend to publish.
Use a small set of browser and Drupal checks:
- Confirm the behaviour works on a fresh page load.
- Confirm it works after an AJAX replacement.
- Test keyboard access and visible focus states.
- Test current Chrome, Firefox, Safari, and mobile browsers.
Accessibility is part of the module rather than a later decoration. Buttons should be real buttons, status messages should be announced when necessary, and interactions should remain usable without a mouse. Australian agencies may also be working towards WCAG expectations in procurement or public-sector projects, making accessible behaviour a practical delivery requirement.
Test Integration And Performance
JavaScript unit tests can validate pure functions, but Drupal integration testing is needed for attachment, permissions, and generated markup. Review the page source or browser network panel to make sure the expected bundle is loaded once and that its path is correct. If the file returns a 404, inspect the library path, the module machine name, and whether the build has actually produced dist/stock-alert.js.
Cache metadata can make an otherwise correct change appear broken. Clear Drupal caches after editing .libraries.yml, Twig templates, or module discovery files. Browser caching can also preserve an old bundle, so use hashed filenames or a versioning strategy when the deployment process requires aggressive cache control. Drupal’s library version can be updated to help invalidate cached assets.
Measure the result rather than assuming bundling is faster. Webpack can reduce requests and minify code, but importing a large package for a small interaction may increase payload size. Prefer small dependencies, tree-shakable modules, and conditional loading for features that appear only on selected routes. A Sydney retail site with a busy mobile audience benefits from a quick initial render even when the full desktop experience includes richer controls.
Security testing should cover both the browser and the server. Review npm audit results, keep dependencies current, and investigate transitive packages before upgrading. In Drupal, check access permissions, route requirements, CSRF protection for state-changing requests, and output escaping. Bundling transforms source code; it does not make unsafe input safe or replace Drupal’s security APIs.
Deploy And Maintain The Module
A production release should build the module in a clean environment, run Drupal checks, and deploy the generated assets together with the PHP and YAML files that reference them. A typical pipeline might run npm ci, npm run build, PHP code quality checks, Drupal tests, and cache rebuild commands. Keep the Node build stage separate from the web server where possible so npm tooling is not exposed to visitors.
Document the commands in the module’s README:
npm ci
npm run build
drush cr
Explain which files are edited by hand, whether dist is committed, and which Drupal routes or blocks use the library. This saves time when another developer takes over the feature or when an agency supports a site after launch. It is also helpful for Australian teams coordinating releases around public holidays, evening maintenance windows, or stakeholders in different states.
Finally, treat the custom module as a maintained product. Review Drupal core compatibility, update npm dependencies on a planned schedule, remove unused packages, and check browser behaviour after major library changes. A small module with a disciplined Webpack build remains easier to debug than a collection of copied scripts, while Drupal continues to manage the content, permissions, caching, and server-side behaviour it handles best.