Sharing JavaScript Libraries Across Pages: The JS Import Map Entry Client Extension (jsImportMapsEntry)
The Problem
Imagine you've built three custom elements for a Liferay site — a distributor map, a promotions widget, and a support ticket form. All three are React applications. Without thinking twice, each one bundles its own copy of React inside its build output.
The result:
Page loads distributor-map.js → downloads React (140kb)
Page loads promotions.js → downloads React again (140kb)
Page loads support-form.js → downloads React a third time (140kb)
Same library, downloaded three separate times, potentially at three different versions if the projects drifted apart. This is the exact problem the JS Import Map Entry client extension (jsImportMapsEntry) exists to solve.
Reading time: 11-13 minutes · Difficulty: Intermediate · Audience: Developers building custom elements, Frontend Engineers
What jsImportMapsEntry Actually Does
A client extension of type jsImportMapsEntry adds entries to Liferay's JS import map — a mapping table that tells the browser how to resolve a module specifier (the string inside an import statement) to an actual file URL.
Once registered, any page element can write:
import React from 'react';
And the browser resolves react to wherever your deployed client extension says it lives — without that element needing to bundle its own copy.
The critical distinction from globalJS: import map entries are not scoped. Anything registered in the import map becomes available system-wide, instance-wide, the moment it's deployed. But it's also not automatically loaded everywhere — the browser only fetches the resource when something on the page actually contains a matching import statement. This combination — globally available, but only loaded on demand — is what makes it more efficient than globalJS for shared dependencies.
When to Use jsImportMapsEntry vs. globalJS
| Question | Use globalJS |
Use jsImportMapsEntry |
|---|---|---|
| Do you need a script to just run on page load? | ✅ | |
| Are you sharing a library across multiple custom elements? | ✅ | |
| Do you need page-scoped or company-scoped control over where it runs? | ✅ | |
| Do you want the browser to skip downloading it if nothing on the page uses it? | ✅ | |
| Is this a one-off script (analytics snippet, polyfill)? | ✅ | |
| Is this a shared dependency (React, a generated API client, a utilities file)? | ✅ |
As Liferay's own documentation puts it: jsImportMaps should be reserved for truly global items that need to be available and consistent everywhere — React, core utilities, animation libraries — while globalJS remains the tool for page-scoped or company-scoped scripts that should simply execute.
Prerequisites
- Liferay Workspace set up locally
- Java installed (per your Liferay version's compatibility matrix)
- Blade CLI installed
- A running Liferay DXP instance to test against
- Basic familiarity with ES6 import/export syntax
Step 1: Create the Client Extension Project
Inside your Liferay Workspace:
cd client-extensions
mkdir my-shared-library
cd my-shared-library
Step 2: Write the Shared Module
Create an assets folder with an index.js entry point. This is where you import the library you want to share, and re-export it:
mkdir assets
assets/index.js:
import jquery from 'jquery';
export default jquery;
This file uses the library — in this case jQuery — and exports it as the module's default export. Anything imported and re-exported here becomes accessible to whatever consumes the client extension later.
You can also export multiple named items from a single shared module, not just a single default:
// assets/index.js
export { default as continents } from './data/continents';
export { default as sayHello } from './util/sayHello';
This lets a single jsImportMapsEntry extension act as a shared utilities package, not just a single library wrapper.
Step 3: Configure client-extension.yaml
# client-extension.yaml
assemble:
- from: build/static
into: static
my-shared-library:
bareSpecifier: jquery
name: My Shared Library
type: jsImportMapsEntry
url: jquery.*.js
Field breakdown:
| Field | Purpose |
|---|---|
assemble |
Copies build output from build/static into the deployable package's static/ folder |
bareSpecifier |
Required. The exact string other code will use in its import statement. This is the "key" other modules use to look up the entry in the import map |
name |
Human-readable label shown in the admin UI |
type |
Must be exactly jsImportMapsEntry |
url |
The path to the built JavaScript resource once deployed |
Important: The bareSpecifier value must match exactly what consuming code writes in its import statement. If bareSpecifier: jquery, then any custom element or fragment that wants to use it must write import jquery from 'jquery' — not import $ from 'jquery-lib' or any variation.
Step 4: Set Up the Build Process
Unlike globalCSS or globalJS, a jsImportMapsEntry extension typically needs a bundler (webpack or Vite) to compile the module and its dependencies into the build/static output your YAML references.
A minimal webpack.config.js for this purpose:
const path = require('path');
module.exports = {
entry: './assets/index.js',
output: {
filename: 'jquery.[contenthash].js',
path: path.resolve(__dirname, 'build/static'),
library: {
type: 'module',
},
},
experiments: {
outputModule: true,
},
mode: 'production',
};
The [contenthash] in the output filename is why the YAML uses a wildcard pattern (jquery.*.js) rather than a fixed filename — Liferay resolves the wildcard to whatever hash the build produced.
Alternative: Reference a CDN directly. If the library is already published on a CDN that serves ES modules, you can skip bundling entirely and point url straight at it:
vacation-react-router-import-maps-entry:
bareSpecifier: react-router-dom
name: React Router DOM Import Maps Entry
type: jsImportMapsEntry
url: https://esm.sh/react-router-dom@6.17.0?external=react
This is a common pattern for third-party libraries you don't need to customize — esm.sh and similar CDNs serve pre-built ES module versions of most popular npm packages.
Step 5: Build and Deploy
npm install
npm run build
blade gw deploy
For Docker-based local instances:
blade gw deploy -Ddeploy.docker.container.id=$(docker ps -lq)
Step 6: Consume the Shared Module
Once deployed, any custom element, fragment, or other client extension can import the shared module using its bareSpecifier:
import jquery from 'jquery';
jquery('.my-element').addClass('active');
From within a Liferay Fragment's JavaScript panel:
import 'clarity-distributor-map-custom-element';
This pattern — a custom element registering itself as a jsImportMapsEntry, and a fragment importing it by bareSpecifier — is how Liferay recommends exposing a custom element's resources to fragments and templates, instead of manually copying and pasting a generated resource URL every time the build hash changes.
Step 7: Verify It's Working
- Open your browser's DevTools → Network tab.
- Load a page containing an element that imports your shared module.
- Confirm the module downloads exactly once, even if multiple elements on the page reference it.
- Load a page that does not reference the module at all, and confirm it does not download — this confirms the "loaded only when referenced" behavior is working as expected.
If the module fails to resolve, work through this checklist:
- Confirm
bareSpecifierin the YAML exactly matches the string in your import statement - Confirm the extension deployed without errors
- Confirm the build output actually landed in the path referenced by
url - Hard-refresh the browser to bypass any cached import map
Real-World Example: One Shared Runtime, Multiple Consumers
Consider a distributor management portal with three separate custom elements, all built in React:
clarity-distributor-map (React custom element)
clarity-promotions-widget (React custom element)
clarity-support-form (React custom element)
Without a shared import map, each bundles its own React runtime — three separate downloads, three separate copies in memory, and a real risk of the three drifting to different React versions over time as each is updated independently.
With a jsImportMapsEntry extension:
clarity-shared-react:
bareSpecifier: react
name: Clarity Shared React Runtime
type: jsImportMapsEntry
url: react.*.js
All three custom elements import React the same way:
import React from 'react';
The browser downloads React exactly once, caches it, and every subsequent custom element on the page reuses that same cached copy. Updating React means updating one client extension — the change applies everywhere instantly, with no risk of version drift between the three elements.
A Note on Styling: jsImportMapsEntry Only Bundles JavaScript
Unlike a full customElement client extension — which can bundle both JavaScript and CSS together — a jsImportMapsEntry extension only handles JavaScript. If the shared module needs accompanying styles, you'll need to either embed the CSS inline within the JavaScript itself, or pair it with a separate globalCSS client extension.
Common Mistakes
1. Mismatched bareSpecifier and Import Statement
# YAML
bareSpecifier: my-lib
// Incorrect — won't resolve
import lib from 'mylib';
// Correct — matches exactly
import lib from 'my-lib';
2. Assuming It Can Be Scoped Like globalJS
There is no scope property for jsImportMapsEntry. Once deployed, the entry is available instance-wide by definition — there's no page-level or company-level restriction to configure. If you need scoping, jsImportMapsEntry is the wrong tool.
3. Forgetting the Wildcard Pattern for Hashed Filenames
If your bundler outputs content-hashed filenames (a common cache-busting technique), your YAML's url needs a wildcard (react.*.js) rather than a literal filename that will change on every build.
4. Expecting CSS to Come Along for Free
As noted above, jsImportMapsEntry bundles JavaScript only. Teams migrating from customElement extensions sometimes assume styling transfers automatically — it doesn't.
5. Treating It as a Replacement for globalJS in All Cases
If your goal is simply "run this script when the page loads" — a tracking snippet, a polyfill, a one-off DOM manipulation — jsImportMapsEntry adds unnecessary complexity. It's built for sharing importable modules between other pieces of code, not for standalone execution.
What Gets Created Behind the Scenes
| Component | Result |
|---|---|
| Import map entry | Registered instance-wide, keyed by bareSpecifier |
| Deployable package | Bundled JS (or CDN reference) served as a static resource |
| Loading behavior | Downloaded only when a page element contains a matching import statement |
| Caching | Browser caches the module after first load; reused across every page and element that imports it |
| Scope | Always instance-wide — no page or company-level restriction available |
Summary
In this tutorial, you:
- Identified the problem of duplicate library bundling across multiple custom elements
- Learned how
jsImportMapsEntrydiffers fromglobalJS— global-but-lazy versus scoped-but-immediate - Created a shared module in
assets/index.js - Configured
client-extension.yamlwith the requiredbareSpecifierandurlproperties - Set up a build process (or alternatively, referenced a CDN-hosted module directly)
- Deployed the extension and consumed it from other code via a standard
importstatement - Walked through a real-world example of three custom elements sharing a single React runtime
The core idea worth remembering: jsImportMapsEntry doesn't push code onto every page — it makes code available to any page, loaded only when something actually asks for it. That distinction is what makes it the correct tool for shared dependencies, and the wrong tool for anything meant to simply run.
References
This tutorial draws on the following official Liferay Learn documentation and community resources:
- JavaScript Import Map Entry YAML Configuration Reference — Liferay Learn
- Bundling Resources in a JavaScript Import Map Entry Client Extension — Liferay Learn
- Building Frontend Applications with JavaScript in Liferay: Sharing Libraries Between Custom Elements — Course module, Liferay Learn
- Integrating Clarity's Application with Fragments — Site Page Integration module, Liferay Learn
- Mastering Liferay Frontend Client Extensions: Implementing Custom Functionality — Course module, Liferay Learn
- Customizing Liferay's Look and Feel — Overview, Liferay Learn
- Sharing JavaScript Code and Libraries — Liferay.dev community blog
Related Reading
- Creating Your First Custom Element: A Step-by-Step Tutorial (React Weather Card) — a full
customElementextension that pairs its React runtime with ajsImportMapsEntryexactly as described here. - Creating Your First Client Extension: A Step-by-Step Tutorial (globalJS) — the extension type this article contrasts
jsImportMapsEntryagainst. - Creating Your First Client Extension: A Step-by-Step Tutorial (globalCSS) — the CSS client extension to pair with a shared module if it needs styling.
- themeCSS Deep Dive: How Liferay's Layer Cake Styling Model Works — how Bootstrap, Clay, and theme CSS layer together, for the styling side of custom elements.
- Browse all Liferay tools — generators and reference utilities for Liferay developers.
