How the ui.frontend Module Turns a Webpack Build into an AEM ClientLib
How the ui.frontend module builds JS/CSS with webpack through frontend-maven-plugin, and how aem-clientlib-generator turns that dist/ output into a cq:ClientLibraryFolder that ui.apps ships to AEM.
ui.frontend is the module in Adobe’s AEM project archetype where frontend
code actually lives: TypeScript/JavaScript, Sass/CSS, and a bundler
configuration, built with npm the same way a standalone frontend project
would be. A frontend developer can work inside it, run npm run start, and
never touch Java or the rest of the Maven reactor. The only AEM-specific
thing about it is what happens at the end of the build: its output has to
become an AEM clientlib, because that’s the only way AEM serves CSS/JS to a
rendered page. This article covers exactly that hand-off — from a webpack
dist/ folder to a cq:ClientLibraryFolder that ends up inside ui.apps.
What ui.frontend actually is
When you run Adobe’s aem-project-archetype, the frontendModule property
picks which flavor of ui.frontend gets generated: general (plain
webpack + TypeScript/Sass), angular, react, or none/decoupled if you
don’t want one. This article focuses on the general flavor, since it’s
the most common baseline and the one most projects start from; the
React and Angular flavors swap the bundler (react-scripts, Angular CLI)
but converge on the same output mechanism described below.
The general module’s package.json declares webpack 5, TypeScript,
Babel, Sass, and ESLint as devDependencies, and exposes these npm scripts:
{
"scripts": {
"dev": "webpack --env dev --config ./webpack.dev.js && clientlib --verbose",
"prod": "webpack --config ./webpack.prod.js && clientlib --verbose",
"start": "webpack-dev-server --open --config ./webpack.dev.js",
},
}
npm run start spins up a webpack-dev-server with live reload against a
static HTML template — useful for markup/style iteration without a running
AEM instance, though it won’t reflect actual AEM-rendered markup.
npm run dev and npm run prod are the two that matter for AEM: they run
a full webpack build and then hand off to aem-clientlib-generator (the
clientlib command), which is the part that actually produces AEM
content.
The ui.frontend/pom.xml wires this into Maven with
com.github.eirslett:frontend-maven-plugin, bound to the
generate-resources phase:
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<executions>
<execution>
<id>npm run prod</id>
<phase>generate-resources</phase>
<goals><goal>npm</goal></goals>
<configuration>
<arguments>run prod</arguments>
</configuration>
</execution>
</executions>
</plugin>
frontend-maven-plugin installs a local Node/npm distribution if needed
and shells out to npm run prod. This is why mvn clean install on a
freshly generated AEM project can build the frontend without anyone
installing Node globally, and why frontend developers can iterate on
ui.frontend with plain npm commands without needing Maven or a JDK at
all — the two toolchains only meet at this one plugin binding. (A fedDev
Maven profile swaps in npm run dev instead, for source maps and
unminified output during local iteration.)
From webpack output to an AEM clientlib
Webpack alone only knows how to produce JS/CSS bundles — it has no idea
what a cq:ClientLibraryFolder is. That translation is the job of the
second command in npm run prod: clientlib --verbose, from the
aem-clientlib-generator
npm package, configured by ui.frontend/clientlib.config.js.
Webpack itself outputs to ui.frontend/dist/, split into two logical
libraries:
dist/clientlib-site/—site.js,site.css, and aresources/folder for images/fonts referenced from CSS.dist/clientlib-dependencies/—dependencies.js,dependencies.css(third-party code, kept separate so it can be cached/updated independently of your own site code).
clientlib.config.js tells aem-clientlib-generator how to turn each of
those folders into an AEM clientlib and, critically, where to write it:
const CLIENTLIB_DIR = path.join(
__dirname,
'..',
'ui.apps',
'src',
'main',
'content',
'jcr_root',
'apps',
'${appId}',
'clientlibs',
);
module.exports = {
context: path.join(__dirname, 'dist'),
clientLibRoot: CLIENTLIB_DIR,
libs: [
{
name: 'clientlib-dependencies',
categories: ['${appId}.dependencies'],
allowProxy: true,
serializationFormat: 'xml',
assets: {
js: { cwd: 'clientlib-dependencies', files: ['**/*.js'] },
css: { cwd: 'clientlib-dependencies', files: ['**/*.css'] },
},
},
{
name: 'clientlib-site',
categories: ['${appId}.site'],
dependencies: ['${appId}.dependencies'],
allowProxy: true,
serializationFormat: 'xml',
assets: {
js: { cwd: 'clientlib-site', files: ['**/*.js'] },
css: { cwd: 'clientlib-site', files: ['**/*.css'] },
resources: {
cwd: 'clientlib-site',
files: ['**/*.*'],
ignore: ['**/*.js', '**/*.css'],
},
},
},
],
};
${appId} is the Maven archetype property chosen when the project was
generated (the short application id, e.g. myproject) — it becomes both
the /apps/<appId> node name and the prefix for every category. Note the
clientLibRoot: it doesn’t point anywhere inside ui.frontend — it points
straight into ui.apps’s source tree. This is the actual mechanism behind
“ui.frontend produces the clientlib that lives in ui.apps”: it’s a plain
file write into a sibling module’s sources, not a packaging step or a
runtime dependency declaration.
Anatomy of the generated clientlib
Given the config above, a build produces this structure directly under
ui.apps/src/main/content/jcr_root/apps/myproject/clientlibs/clientlib-site/:
clientlib-site/
├── .content.xml
├── js.txt
├── css.txt
├── js/
│ └── site.js
├── css/
│ └── site.css
└── resources/
└── site.js.map
.content.xml declares the cq:ClientLibraryFolder node with the
properties from the config:
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:ClientLibraryFolder"
allowProxy="{Boolean}true"
categories="[myproject.site]"
dependencies="[myproject.dependencies]"/>
categoriesis what HTL and the Page Policy reference to pull this library into a page — it has no relation to the folder name.dependenciestells AEM’s HTML Library Manager to always resolve and emitmyproject.dependenciesbeforemyproject.site, so third-party code loads first regardless of markup order.allowProxyexposes the library under the versioned/etc.clientlibs/...proxy path (required on AEM as a Cloud Service, since/appsisn’t publicly served) instead of the raw/apps/myproject/clientlibs/...path.embed(not used above, but common in hand-written clientlibs) lets one library statically pull the contents of another category into itself at build/request time — you’ll see it onmyproject.dependenciesin projects that embed a shared vendor clientlib rather than declaring it as a plain dependency.
js.txt is the manifest the HTML Library Manager reads to know which
files under js/ to concatenate, and in what order:
#base=js
site.js
css.txt follows the same pattern against the css/ folder. Because
aem-clientlib-generator regenerates both files on every build from
whatever webpack emitted, you never hand-maintain them for this module —
which is also exactly why you shouldn’t hand-edit them (more on that
below).
The hand-off to ui.apps
ui.frontend and ui.apps are independent Maven modules with no
<dependency> between them, but the parent pom.xml lists ui.frontend
before ui.apps in <modules>, and Maven’s reactor preserves that
declared order when there’s no dependency graph forcing otherwise. So in a
full mvn clean install, by the time ui.apps’s
filevault-package-maven-plugin walks ui.apps/src/main/content/jcr_root
to build the content package, the clientlib-site and
clientlib-dependencies folders are already sitting on disk there —
written by aem-clientlib-generator during ui.frontend’s earlier
generate-resources phase. ui.apps then packages them like any other
content under jcr_root, with no special-casing.
That’s the entire hand-off: a file-system write from one module into
another’s source tree, timed by Maven phase ordering. It’s also why
running mvn -pl ui.apps (or any partial reactor that skips
ui.frontend) silently ships whatever was last generated on disk, stale
or not — Maven has no way to know the frontend needs rebuilding unless
ui.frontend is actually in the reactor for that run.
Once the clientlib is inside a deployed ui.apps package, an HTL script
references its category — never a file path — through the Core Components’
clientlib template:
<sly
data-sly-use.clientlib="core/wcm/components/commons/v1/templates/clientlib.html"
data-sly-call="${clientlib.css @ categories='myproject.site'}"
/>
<sly data-sly-call="${clientlib.js @ categories='myproject.site'}" />
In practice, most archetype-generated projects don’t even hard-code that
in every component — clientlib-site and clientlib-dependencies are
wired into the page’s Page Policy (Content Page Template → Page
Information → Page Policy) so every page using that template gets them
automatically.
Where this comes up
- A frontend change built fine but never shows up on the page. Check,
in order: did
ui.frontendactually run in this build (a partialmvn -pl ui.appsreactor won’t touch it, and won’t complain either); didnpm run prod/devfinish without silently failing before theclientlibstep; and only then suspect caching. - Stale clientlib cache. The merged/minified
site.js/site.cssfiles don’t change name between builds (no content hash in the filename), so browser and dispatcher/CDN caches can keep serving an old version after a successful redeploy. Append?debugClientLibs=trueto the page URL to disable merging and minification and load each source file individually — it tells you immediately whether you’re looking at a stale cache or a build that genuinely didn’t include your change. - Category mismatch. If the
appIdarchetype property or the category names inclientlib.config.jschange but the Page Policy or an HTL script still references the old category string, the build succeeds and the clientlib exists in the repository — it’s just never included on any page. - Editing the generated files directly. Never hand-edit anything under
ui.apps/.../clientlibs/clientlib-site/. Thosejs/,css/,js.txt, and.content.xmlfiles areaem-clientlib-generatoroutput, fully regenerated on everynpm run prod/dev. The real source lives underui.frontend/src; an edit made directly inui.appssurvives only until the next frontend build silently overwrites it.