• Global. Remote. Office-free.
  • Mon – Fri: 8:00 AM to 5:00 PM (Hong Kong Time)
English

Warning: foreach() argument must be of type array|object, bool given in /var/www/html/wp-content/plugins/wp-builder/core/Components/ShiftSaas/Global/topbar.php on line 50

Improving AEM Front-End Development Workflow

By Vuong Nguyen September 29, 2025 13 min read

In the previous article, Validate AEM Dispatcher Config like Cloud Manager, we looked at keeping dispatcher configs in sync with Adobe’s validation process. That was mainly about the back-end setup.

This time, we will switch to the front-end workflow — where scripts, styles, and component assets live. With Webpack, clientlibs, and linting, you will learn how to:

  • bundle and copy assets into AEM,
  • enforce coding standards for JS, TS, and CSS,
  • and debug/optimize more effectively.

Setting Up Webpack Entry and Output

The package.json configuration in the ui.frontend module illustrates how scripts and dependencies are organized for front-end development.

Next, let us look at how Webpack handles the entry point and output path, which determine where compiled assets are placed.

» webpack.common.js

entry: {
        site: SOURCE_ROOT + '/site/main.ts'
},
output: {
    filename: (chunkData) => {
        return chunkData.chunk.name === 'dependencies' ? 'clientlib-dependencies/[name].js' : 'clientlib-site/[name].js';
    },
    path: path.resolve(__dirname, 'dist')
},

After setting up the entry and output, we need to adjust SOURCE_ROOT. Since the Webpack config is placed inside the webpack folder, redefining SOURCE_ROOT ensures that script paths resolve properly as part of the improved front-end workflow.

» webpack.common.js

'use strict';

const path = require('path');
const config = require("./config.json").webpack;
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TSConfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');

const plugins = require('./webpack.plugins');

const ___dirname = path.resolve(__dirname, '..');
const SOURCE_ROOT = ___dirname + '/src/core/scripts';

// Entry points resolver
const entry = (function () {
  let entryPoints = {};
  Object.keys(config.entry).forEach(key => {
    entryPoints[key] = path.resolve(__dirname, config.entry[key]);
  });
  return entryPoints;
}());

Once SOURCE_ROOT has been adjusted, the entry point comes into play. In this project, main.ts is responsible for importing global styles and recursively loading JavaScript and TypeScript modules.

// Stylesheets
import "./main.scss";

// Javascript or Typescript
import "./**/*.js";
import "./**/*.ts";
import '../components/**/*.js';

In many tutorials, JavaScript and TypeScript files are grouped into a single folder (e.g., login.jsregister.jsvalidate.js, …). However, if we organize our code by component — placing scripts and styles together (e.g., login/login.js and login/login.scss) — we can follow a different approach, as shown below.

// Stylesheets
import "./main.scss";

/* Components */
import '../../components/content/helloworld/helloworld.js';
import '../../components/content/login/login.js';
import '../../components/content/register/register.js';
import '../../components/content/validate/validate.js';

Bundle optimization comes next. The analyzeBundle script in package.json uses webpack-bundle-analyzer to show what’s inside the build.

» packages.json

"scripts": {
  ...
  "analyzeBundle": "webpack-bundle-analyzer --port 4200 ./dist/stats.json",
  ...
},

Before running the analyzeBundle script, install webpack-bundle-analyzer in the ui.frontend module with the following command:

npm i webpack-bundle-analyzer

After installing webpack-bundle-analyzer, the next step is to configure it. We will create a webpack.plugins.js file and register the plugin so it can be included in webpack.common.js.

» webpack.plugin.js

const _BundleAnalyzerPlugin = require("webpack-bundle-analyzer/lib/BundleAnalyzerPlugin");

const BundleAnalyzerPlugin = new _BundleAnalyzerPlugin({
    analyzerMode: 'disabled',
    generateStatsFile: true,
    statsOptions: { source: false }
});

module.exports = [
    BundleAnalyzerPlugin
];

Once configured, running webpack-bundle-analyzer starts a local server at http://127.0.0.1:4200 where you can explore a visual breakdown of your bundled JavaScript files.

A treemap view showing the composition of the clientlib-site/site.js bundle with _helloworld.js, main.ts, and main.scss as major modules.

Instead of keeping MiniCssExtractPluginESLintPlugin, and CleanWebpackPlugin inside webpack.common.js, we move them into webpack.plugins.js. Then, in webpack.common.js, we simply import that file and spread its contents into the plugins array, while still adding others like CopyWebpackPlugin:

const plugins = require('./webpack.plugins');

plugins: [
    ...plugins,
    new CopyWebpackPlugin({
      patterns:
        [
          {from: path.resolve(__dirname, SOURCE_ROOT + '/resources'), to: './clientlib-site/'}
        ]
    })
],

Now that the plugins are in place, let us make debugging easier. Add devtool: "source-map" to your webpack.common.js. This tells Webpack to generate source maps so you can trace errors back to the original TypeScript or JavaScript code instead of the bundled output.

// webpack.common.js
module.exports = {
    resolve: resolve,
    devtool: 'source-map',
    entry: {
        site: SOURCE_ROOT + '/site/main.ts'
    },
    ...
}

With source maps enabled for debugging, the next improvement is adding linting for stylesheets. Stylelint helps enforce coding standards, keep styles consistent, and automatically fix common issues. Add it to your webpack.plugins.js like this:

const _StylelintPlugin = require('stylelint-webpack-plugin');

const StylelintPlugin = new _StylelintPlugin({
    extensions: ['pcss'],
    fix: true
});

module.exports = [
    StylelintPlugin
];

// packages.json
"stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0",
"stylelint-order": "^4.1.0",
"stylelint-webpack-plugin": "^2.2.0",

After setting up Stylelint for stylesheets, the next step is handling configuration values. In larger projects or when working with sensitive data (like Cognito settings), using DefinePlugin is a clean way to inject environment variables into your build.

const _DefinePlugin = require('webpack').DefinePlugin;

const DefinePlugin = new _DefinePlugin({
  COGNITO_REGION: JSON.stringify(process.env.COGNITO_REGION),
  COGNITO_USER_POOL_ID: JSON.stringify(process.env.COGNITO_USER_POOL_ID),
  COGNITO_CLIENT_ID: JSON.stringify(process.env.COGNITO_CLIENT_ID),
});

module.exports = [
    DefinePlugin
];

// .env
COGNITO_REGION = xxxxxxx
COGNITO_USER_POOL_ID = xxxxxxxxxxxxxxx
COGNITO_CLIENT_ID = xxxxxxxxxxxxxxxxxxxxxxxxxxx

After setting up environment variables with DefinePlugin, the next step is to centralize entry configuration in a config.json file and update webpack.common.js. Before that, run npm run clientlibs to generate the JS and CSS bundles that will be copied into the ui.apps module in AEM.

// ui.frontend/webpack/config.json

{
  "aem": {
    "clientlibsRoot": "ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/",
    "componentsRoot": "ui.apps/src/main/content/jcr_root/apps/flagtick/components/"
  },
  "webpack": {
    "entry": {
      "site": "../src/core/scripts/main/main.ts",
      "author": "../src/author/scripts/main/main.ts"
    },
    "publicPath": "/etc.clientlibs/flagtick/clientlibs/clientlib-site/resources/"
  }
}

Run the following command to generate the JavaScript and CSS bundles, copy them into the ui.apps module in AEM, and organize them within the correct folder structure.

npm run clientlibs

Example output:

processing clientlib: clientlib-dynamic-modules
Write node configuration using serialization format: xml
write clientlib json file: /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-dynamic-modules/.content.xml

processing clientlib: clientlib-author
Write node configuration using serialization format: xml
write clientlib json file: /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-author/.content.xml

write clientlib asset txt file (type: css): /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-author/css.txt
copy: clientlib-author/author.css /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-author/css/author.css

processing clientlib: clientlib-author-dialog
Write node configuration using serialization format: xml
write clientlib json file: /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-author-dialog/.content.xml

write clientlib asset txt file (type: js): /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-author-dialog/js.txt

write clientlib asset txt file (type: css): /Users/macbook/Documents/Work/Flagtick/Code/ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs/clientlib-author-dialog/css.txt

This is a simplified description that shows the clientlibs JSON copying process without detailing each step individually, including:

  • starting the aem-clientlib-generator
  • processing the clientlib: clientlib-site
  • processing the clientlib: clientlib-dynamic-modules
  • processing the clientlib: clientlib-author

Client Libraries and Front-end Workflow

We have diagram which shows shows how client libraries are organized in an AEM project. Core clientlibs and project-specific bundles work together with the Webpack output, providing CSS to the page head and JavaScript to the bottom of the HTML body.

Under ui.apps/src/main/content/jcr_root/apps/flagtick/clientlibs, you can see the defined client libraries (clientlib-baseclientlib-gridclientlib-dependencies, and clientlib-site). Meanwhile, the ui.frontend module exists as a separate workspace where Webpack compiles assets.

ui.apps holds the clientlib definitions, while ui.frontend handles the build. Webpack compiles the source into the dist folder, and the output is copied into ui.apps so AEM can serve the CSS and JavaScript.

In ui.frontend/src, code can be structured in two ways. The left groups files by type (styles, scripts, resources), while the right groups them by feature (helloworld, login, register, validate). For larger projects, the feature-based approach is easier to manage since each component keeps its JS and SCSS together.

We will now look at how this source structure is applied in practice. In src/core/scripts/main/main.ts, the core folder acts as the entry point for custom code. Here you can define component-specific logic (TypeScript and SCSS/PCSS), reference JavaScript libraries from node_modules, and manage shared assets like fonts, icons, images, and utility classes.

In addition to the core entry point, you can also use the author entry to add custom CSS for AEM authoring interfaces, such as styling the cq-Overlay--component class. For example, the rule in column-control-authoring.pcss adjusts the overlay container display:

// ui.frontend/src/author/postCss/column-control-authoring.pcss

.cq-Overlay--component .cq-Overlay--container {
    display: inline;
}

In cases where we need to process .pcss or .scss files, Webpack must be configured with the proper loaders. The next step is to add the following rule to your Webpack configuration:

// ui.frontend/webpack/webpack.common.js
{
    test: /\.pcss$/,
    exclude: /node_modules/,
    use: [
      {
        loader: MiniCssExtractPlugin.loader,
        options: {
          publicPath: config.publicPath,
        }
      },
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1
        },
      },
      {
        loader: 'postcss-loader',
        options: {
          postcssOptions: {
            config: path.resolve(__dirname, 'postcss.config.js'),
          },
        },
      },
    ],
}

Since Webpack uses postcss-loader to process .pcss files, the required PostCSS plugins must also be included in your package.json. These plugins handle tasks like nesting, imports, variables, and responsive unit conversion.

» package.json
...
"postcss": "^8.2.15",
"postcss-loader": "^3.0.0",
"postcss-advanced-variables": "^3.0.1",
"postcss-compact-mq": "^0.2.1",
"postcss-extend-rule": "^3.0.0",
"postcss-font-smoothing": "^0.1.0",
"postcss-import": "12.0.1",
"postcss-inline-svg": "^4.1.0",
"postcss-nested": "^4.2.3",
"postcss-nested-ancestors": "^2.0.0",
"postcss-preset-env": "^6.7.0",
"postcss-pxtorem": "^5.1.1",
"postcss-reporter": "^6.0.0",
"postcss-scss": "^3.0.5",
...

Besides setting up the PostCSS plugins, you also need to configure Stylelint to handle .pcss files. This ensures consistent coding standards and avoids false errors for custom rules or properties. Add the following configuration to your .stylelintrc.json:

{
  "extends": "stylelint-config-standard",
  "plugins": ["stylelint-order"],
  "rules": {
    "order/properties-alphabetical-order": true,
    "at-rule-no-unknown": [
      true,
      {
        "ignoreAtRules": [
          "each",
          "else",
          "extend",
          "function",
          "if",
          "include",
          "lost",
          "mixin",
          "svg-load"
        ]
      }
    ],
    "property-no-unknown": [
      true,
      {
        "ignoreProperties": [
          "lost-center",
          "lost-column",
          "lost-column-rounder",
          "lost-flex-container",
          "lost-move",
          "lost-row",
          "lost-vars",
          "lost-utility",
          "font-smoothing"
        ]
      }
    ],
    "indentation": [4, { "baseIndentLevel": 0 }],
    "selector-type-no-unknown": [true, { "ignore": ["/^^/"] }],
    "declaration-no-important": false,
    "media-feature-range-operator-space-after": "never"
  }
}

After configuring Stylelint, the next step is to set up a postcss.config.js file. This file defines how PostCSS processes your styles, including imports, variables, nesting, and optimizations. Below is an example configuration aligned with the plugins declared in package.json:

// postcss.config.js
const postCssSCSS = require('postcss-scss');
const path = require('path');

const paths = {
    components: path.resolve(__dirname, '../src/core/components/'),
    icons: path.resolve(__dirname, '../src/core/resources/icons/'),
    modules: path.resolve(__dirname, '../node_modules/'),
};

module.exports = {
    syntax: postCssSCSS,
    plugins: [
        ['postcss-import', { path: [`${paths.components}`, `${paths.modules}`] }],
        'postcss-advanced-variables',
        'postcss-font-smoothing',
        'postcss-nested-ancestors',
        'postcss-nested',
        'postcss-compact-mq',
        ['postcss-preset-env', { browsers: 'last 2 versions' }],
        'lost',
        ['cssnano', {
            preset: ['default', {
                normalizeWhitespace: false,
                discardComments: { removeAll: true }
            }]
        }],
        ['postcss-inline-svg', { paths: [`${paths.icons}`], removeFill: true }],
        ['postcss-extend-rule', { onRecursiveExtend: 'throw', onUnusedExtend: 'throw' }],
        ['postcss-pxtorem', { propList: ['*'] }],
        'postcss-reporter'
    ],
};

Then, run npm run dev to verify the setup. This will start the aem-clientlib-generator and show the clientlibs being processed, for example: clientlib-site.

...
start aem-clientlib-generator
...
processing clientlib: clientlib-site

Go

#Adobe Experience Manager