Table of Contents

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.js and 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 — how remoteEntry.js is 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:

  1. The React shell (apps/cms in MibFrontEnd) is configured with between one and three remote URLs — passed as container env vars CUSTOM_COMPONENTS_URL / CUSTOM_COMPONENTS_2_URL / CUSTOM_COMPONENTS_3_URL to the shell's mib-cms-frontend-react service. The shell's entrypoint script reads them at boot and writes them into the served config.json before nginx starts.
  2. On first render of a page that uses a widget from a given remote, the shell dynamically imports https://<remote-host>/assets/remoteEntry.js.
  3. The shell's component dispatcher looks up schema.components[i].type in the remote's exported components map 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 shared block. Every remote that lists it under shared will 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.js small. Avoid import of large libraries at the top level of src/main.tsx. Prefer dynamic await import(...) inside an effect or event handler.
  • Use React.lazy + Suspense for 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