Shipping Custom JavaScript
Custom JavaScript for the CMS reaches the operator's browser through exactly one path: it's bundled into a federated React remote that the React shell loads on demand. There is no per-page "embed a script" step, no global script registration, no JS path stored in a database row.
This page covers how the bundling works, how to share modules across remotes, how to lazy-load heavy libraries, and how to integrate third-party scripts.
TL;DR. Your custom JS is just the TypeScript/JavaScript inside your federated remote. Vite bundles it into
assets/remoteEntry.jsand the React shell dynamically imports the entry on first use of any widget from that remote.
Related docs.
Component Authoring — the full C# + React component lifecycle.
React Hooks & Events — the API surface custom widgets can consume.
System Overview — federated remotes — howremoteEntry.jsis fetched and dispatched.
Where custom JS lives
In your federated remote — a separate Vite app + Docker image
whose assets/remoteEntry.js the React shell loads at runtime. The
remote compiles all of its TypeScript / JavaScript into a single
remoteEntry.js plus a handful of lazy-loaded chunks.
At runtime:
- The React shell (
apps/cmsin MibFrontEnd) is configured with between one and three remote URLs — passed as container env varsCUSTOM_COMPONENTS_URL/CUSTOM_COMPONENTS_2_URL/CUSTOM_COMPONENTS_3_URLto the shell'smib-cms-frontend-reactservice. The shell's entrypoint script reads them at boot and writes them into the servedconfig.jsonbefore nginx starts. - On first render of a page that uses a widget from a given remote,
the shell dynamically imports
https://<remote-host>/assets/remoteEntry.js. - The shell's component dispatcher looks up
schema.components[i].typein the remote's exportedcomponentsmap and renders the corresponding React widget.
You do not add a <script> tag to any page, do not register the
JS with the BFF, do not include the JS path in any database row. The
component-type key in the schema is enough — the shell already knows
where the remote is (from its build-time or runtime config) and
fetches the entry on demand.
A minimal custom remote
The recommended scaffold for a customer remote is a Vite app with
@originjs/vite-plugin-federation:
// vite.config.ts (excerpt)
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
federation({
name: 'customComponents',
filename: 'remoteEntry.js',
exposes: {
'./all': './src/main.tsx',
},
shared: [
'react',
'react-dom',
'@agilecontent/ui',
'@agilecontent/mib-modules',
],
}),
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
});
src/main.tsx is the single entry point — it exports a default
object that the React shell merges into its component registry:
// src/main.tsx
import MyCustomWidget from './components/MyCustomWidget';
import AnotherCustomWidget from './components/AnotherCustomWidget';
import myWidgetTranslations from './translations';
export default {
components: {
'my_custom_widget': MyCustomWidget,
'another_custom_widget': AnotherCustomWidget,
},
decorators: {
relatedList: {
headerActions: { /* ... */ },
itemActions: { /* ... */ },
},
},
translations: myWidgetTranslations,
};
The shell expects components, decorators, and translations as
optional top-level keys; arbitrary additional keys are merged
verbatim. See Component Authoring for the
component-type key contract.
Sharing modules across remotes
When two remotes import the same package (react, antd,
@agilecontent/ui, @agilecontent/mib-modules), the shell
deduplicates them through Module Federation's shared declaration.
The host's vite.config.ts lists the shared modules:
// apps/cms/vite.config.ts (excerpt)
federation({
shared: ['react', 'react-dom', '@agilecontent/ui', '@agilecontent/mib-modules', /* ... */],
})
Each remote declares the same packages in its own vite.config.ts
shared block. At runtime each shared module is fetched once and
reused — without this, every remote would carry its own React copy
and the rules of hooks would silently break across federation
boundaries.
If you ship a domain-specific library (@my-customer/utils), you
can choose:
- Don't share (default). The library is bundled into your
remote's
remoteEntry.js. Multiple remotes that use it each carry their own copy. Fine for small libraries. - Share via the host. Add the library to the host's
sharedblock. Every remote that lists it undersharedwill use the host's copy. Required if the library holds singleton state (a store, a registry) that must be the same instance across remotes.
Common JS patterns
| Need | Solution |
|---|---|
| Embed component-local code | TypeScript inside the widget file; bundled into the remote |
| Run code on first mount | useEffect(() => { ... }, []) |
| Measure DOM before paint | useLayoutEffect |
| Share helpers between two widgets in the same remote | Move into a shared module inside the remote |
| Share helpers between widgets in different remotes | Package the helper as an npm module; add to shared in both vite.config.ts files |
| Lazy-load a heavy third-party library | const Chart = (await import('chart.js')).default; inside an effect — Vite splits it into a separate chunk |
| React to the page's save | useContentEdition().onSave(...) — see React Hooks & Events |
| React to filter / sort changes | useFilters() / useSorter() |
| Show a confirmation modal | useNotification().modal({...}) |
Third-party scripts (analytics, video players, …)
For genuinely external scripts (Google Analytics, Mux, FullStory)
prefer loading them in your widget via a one-shot <script>
injector:
useEffect(() => {
if (document.querySelector('#my-analytics')) return;
const s = document.createElement('script');
s.id = 'my-analytics';
s.src = 'https://analytics.example.com/script.js';
s.async = true;
document.head.appendChild(s);
}, []);
This keeps the loader idempotent (the querySelector guard
prevents double-injection) and means the script is only present on
pages where the widget actually mounts.
If a script needs to be always present (e.g. session-level
analytics), ship it in the React shell (apps/cms's index.html
template) rather than in a federated remote — remotes can come and
go, but the shell is always present.
Bundle size and lazy-loading
Module Federation eagerly loads your remote's remoteEntry.js the
first time any of its widgets is needed on a page. After that the
manifest knows what other chunks to fetch — and they fetch lazily.
Practical guidance:
- Keep
remoteEntry.jssmall. Avoidimportof large libraries at the top level ofsrc/main.tsx. Prefer dynamicawait import(...)inside an effect or event handler. - Use
React.lazy+Suspensefor components that aren't visible immediately (e.g. inside a tab that's closed by default). - The shell shows a skeleton while a remote is loading. If you see a noticeable spinner on first-paint, your remote is too big — split it.
Runtime configuration
Customer remotes are typically configured at runtime (not build
time) through a single config.json read on shell startup. This
keeps one Docker image deployable to multiple environments.
The shell reads CUSTOM_COMPONENTS_URL, CUSTOM_COMPONENTS_2_URL,
and CUSTOM_COMPONENTS_3_URL container env vars on startup, writes
them into the served config.json, then the bundle loads them at
runtime. Each customer customisation can choose to fetch its own
remote-specific runtime config from a known endpoint in the same
way — config.json at the remote's root, parsed in main.tsx before
any widget renders.
Legacy MVC reference
@Html.DelayedScript and the legacy GetScripts() / GetStyles()
mechanism are documented under Legacy JavaScript API.
New code must not use them.
See also
- Component Authoring — full C# + React component lifecycle
- React Hooks & Events — the React API surface custom widgets consume
- System Overview — federated remotes
— how
remoteEntry.jsis fetched and dispatched - Theming & CSS — CSS variables and shell theming