AEM Guide

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.frontendwebpackclientlibmavenfrontend-maven-plugin

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:

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]"/>

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