The extension system
Introduced by RFC-27 , the extension system enables frontend modules to insert UI elements into each other, and for these interactions to be configurable by system administrators.
Those familiar with the OpenMRS RefApp 2.x extension
system will be glad to know that the basic concepts
here are similar, but simpler. “Extensions” are roughly the same thing as before, extension “points” are now called
“slots,” and O3 frontend modules use routes.json instead of RefApp-style app definitions.
Key concepts
The extension system posits two concepts: extensions and slots. An extension is a component. A slot is a place in the UI.
Extensions get rendered into slots. An extension gets associated with a slot in one of the following ways:
- The extension names the slot in its definition, using the
slotorslotsproperty. - A call to the
attachfunction. - A system administrator adds the extension to the slot using the slot’s
addarray.
Slot names are global at runtime. If two modules render the same slot name, O3 treats that as the same extension point; if they try to register ownership from different modules, the runtime warns and keeps the first owner.
When to use extensions and slots
The extension system should be thought of as a system for making behavior configurable by administrators. It should not be thought of as a way to reuse components across modules.
This key question is: Am I creating a collection of similar things, such as buttons or tiles, which an administrator might want to re-order or otherwise change?
If so, this may be a good place to use extensions.
What if I just want to mount something from one framework into something in another framework?
Just use the Single SPA mountParcel function.
What if I just want to use a component from one module in a different module, and I can change both?
Consider exporting the component and using it the normal way.
Usage
Extensions are defined in a module’s routes.json file. This is the current approach for O3 frontend modules. The older setupOpenMRS registration approach from Core v4 and earlier is not supported in Core v5+. Each extension definition includes a name and a component reference. It may also specify the names of slots to attach the extension to by default, required privileges for role-based access control, connectivity requirements, feature flags, display expressions, and meta.
Slots are components. There is an ExtensionSlot React component. If you are working in a different framework and would like to create an extension slot, please get in touch with the OpenMRS Frontend 3.0 team on Slack.
At runtime, the flow is:
- The distro build includes each module’s
routes.jsoninroutes.registry.json. - The app shell reads the routes registry and registers each extension definition.
- A host module renders
<ExtensionSlot name="..." />. - O3 computes the assigned extension IDs from default
slot/slotsattachments plus administratoradd,remove,order, andconfiguresettings. - O3 filters assigned extensions by privileges, display expressions, feature flags, and the extension definition’s
online/offlineflags. - The slot renders each extension as a single-spa parcel and passes
_meta,_extensionContext, and any slotstateprops.
Principles
Nomenclature
Naming extensions
An extension will have a name which identifies it. That name should describe what the extension does. It should not have anything to do with where the extension will appear in the application. It has no innate sense of place.
✅ Good extension names:
- Vitals table
- User avatar
- Biometrics tile
❌ Bad extension names:
- Top bar (“top” indicates a place)
- Home page reports link (“home page” indicates a place)
- Steve (names should be descriptive)
Note: You will likely see a lot of extension and slot names which are all lowercase with dashes. This is not necessary; it is better to give extensions names that are pleasant to read. Similarly, you will see many slots suffixed with “slot.” This is also not necessary.
Naming slots
A slot will also have a name which identifies it. That name should describe the location in the app that it represents. If it describes the things that can go in it, it should only use the most general terms imaginable—things like “button” or “tile” or “widget”.
✅ Good slot names:
- Primary nav right menu
- Patient header detail box
- Form header buttons
❌ Bad slot names:
- Patient address (too prescriptive about contents)
- homepage-widgets-slot (should be
Homepage widgets) - Extra buttons (too vague)
Styling
An extension should be as agnostic as possible to the context in which it appears. This means that you should avoid defining the size of an extension. Extensions should be responsive (within reason), such that the contents will adapt to a variety of different extension dimensions.
Slots should be responsible for as much styling as generically applies to all of their contents. If all of the extensions in a slot should have a border, the slot should apply the border. The slot should also be responsible for setting the dimensions into which the extensions will render.
A slot can apply styles to an extension with the following CSS selector:
.slot > * > * {
...;
}Extension configurability
The beautiful thing about configurability in the extension system is that you don’t need to think about it. Extensions and slots have a standard configuration interface that allows administrators to add, remove, and re-order extensions, as well as configure extension-specific settings within a particular slot.
You can use useConfig as usual within an extension.
The schema for an extension can be specified using defineExtensionConfigSchema. If no schema is defined specifically for your extension, the extension will inherit the configuration of the module that contains it.
Role-based access control (RBAC)
Extensions support role-based access control (RBAC) through privileges. You can declare required privileges directly in the extension definition in routes.json, and administrators can override or refine these requirements via configuration.
Declaring privileges in routes.json:
{
"extensions": [
{
"name": "vitals-widget",
"component": "vitals",
"slot": "patient-header-slot",
"privileges": ["View Vitals"]
}
]
}The privileges property can be a single string or an array of strings. If an array is provided, the user must have all of the specified privileges to see the extension. If a single string is provided, the user must have that specific privilege. If the user doesn’t have the required privileges, the extension will not be rendered. Note: Users with the “System Developer” role bypass privilege checks.
Overriding privileges via configuration:
Administrators can override the extension’s default privilege requirements using the Display conditions configuration object (described in the next section). This allows fine-tuning access control without modifying code.
Display conditions
Every extension automatically has a Display conditions configuration object available, which allows administrators to control when extensions are displayed without modifying code. Display conditions are evaluated during the filtering phase before extensions are rendered. This is particularly useful for fine-tuning the role-based access control requirements set in routes.json or implementing implementation-specific access policies.
The Display conditions object supports the following properties:
-
privileges(array of strings): Overrides the extension’s default privileges requirement declared inroutes.json. If specified, the user must have all of these privileges to see the extension. If not specified, the extension’s defaultprivilegesproperty from its definition is used. This allows administrators to customize access control per implementation without code changes. Note: Users with the “System Developer” role bypass privilege checks. -
expression(string): A boolean JavaScript expression that must evaluate totruefor the extension to display. The expression has access to asessionobject containing:session.authenticated- whether the user is authenticatedsession.user- the logged-in user object (with properties likeuuid,username,systemId,privileges,roles, etc.)session.sessionLocation- the current session locationsession.currentProvider- the current providersession.locale- the current locale
The expression context also includes values from the slot’s
stateobject, so patient-scoped slots can expose values likepatientUuidto display expressions.
Display conditions can be provided as base extension configuration, or as a slot-specific override under the slot owner’s extensionSlots.<slotName>.configure object. If the extension has an extension-specific schema, base values belong under the extension name; otherwise they belong under the extension module name. Slot-specific overrides are the right choice when the same extension appears in more than one slot and needs different visibility rules in each place.
Connectivity is controlled by the extension definition’s online and offline fields in routes.json, not by Display conditions configuration.
Example configuration:
Slot configuration belongs under the module that owns the slot. In this example, @openmrs/esm-patient-chart-app is the module rendering patient-header-slot.
{
"@openmrs/esm-patient-chart-app": {
"extensionSlots": {
"patient-header-slot": {
"configure": {
"vitals-widget": {
"Display conditions": {
"privileges": ["View Vitals", "Manage Vitals"],
"expression": "session.user.systemId !== 'admin' && session.sessionLocation?.uuid === 'some-location-uuid'"
}
}
}
}
}
}
}This configuration would:
- Require the user to have both “View Vitals” and “Manage Vitals” privileges
- Hide the extension for users with systemId “admin”
- Only show the extension when the session location matches a specific UUID
UI Editor
The Implementer Tools app includes a UI Editor feature that provides a visual interface for configuring extensions. When enabled, it displays overlays on extension slots and extensions throughout the application, allowing administrators to:
- Click on slots to configure which extensions appear in them
- Click on extensions to configure their settings within a specific slot
- Visually identify where extensions are rendered
- See extension counts and metadata at a glance
The UI Editor uses the data-extension-slot-name and data-extension-id attributes that are automatically added to slot and extension DOM elements. The position: relative styling on extension containers enables the UI Editor to position its overlay elements correctly.
To enable the UI Editor, open the Implementer Tools (usually via a keyboard shortcut or menu) and toggle the “UI editor” switch in the configuration panel.
State
Sometimes, extensions are not as independent as we might wish they were, and have to expect some state from the slot in which they are mounted. Most commonly, extensions that pertain to a specific patient will accept a patientUuid parameter which can be used to fetch relevant patient information.
State is provided as a parameter to the ExtensionSlot or Extension components, and received as a prop by the extension.
Using the Extension component:
When you provide custom children to ExtensionSlot (either as a React node or a function), you must use the <Extension /> component to mark where each extension should be rendered. The Extension component is a React helper that renders a single extension instance within a slot.
If ExtensionSlot has custom children, pass slot state to <Extension state={...} />. Passing both children and state directly to ExtensionSlot will throw.
import { ExtensionSlot, Extension } from "@openmrs/esm-framework";
// With custom wrapper
<ExtensionSlot name="patient-header-slot">
<div className="custom-wrapper">
<Extension />
</div>
</ExtensionSlot>
// With function children
<ExtensionSlot name="dashboard-slot">
{(extension) => (
<div className={`widget-${extension.meta?.size}`}>
<Extension state={{ patientUuid: "123" }} />
</div>
)}
</ExtensionSlot>If you don’t provide children to ExtensionSlot, the Extension component is automatically rendered for each extension.
See the ExtensionSlot API docs for more.
Meta
Sometimes, extensions might want to pass information to the slot that receives them. This is used, for example, by patient chart widgets. Dashboards render these widgets into a grid format. When a dashboard receives a widget, the widget informs the dashboard (which is a slot) how many grid columns it would like to take up. This happens using meta.
Meta is provided by extensions in their definition in routes.json. (In Core v4 and earlier, this was done in the setupOpenMRS function, but that approach is no longer supported in Core v5+.)
Slots can access meta through the extension system API, such as by using useExtensionSlotMeta. meta comes from the extension definition; use slot configure settings when administrators need to override extension-specific configuration.
Order
By default, extensions render in the order they are declared or attached. The final order is determined by a priority system with three tiers:
- Configured order (highest priority): Extensions listed in the slot’s
orderconfiguration array appear first, in the order specified in that array. - Registered order: Extensions with an
orderproperty in theirroutes.jsondefinition appear next, sorted by their order value (offset by 1000 to ensure they come after configured extensions). - Attached order (lowest priority): Extensions without any order specification appear last, in the order they were attached (offset by 2000 to ensure they come after all ordered extensions).
Extensions added by administrators via the add array participate in configured order and registered order. If an added extension is not listed in order and has no registered order value, the runtime has no attachment index for it, so do not rely on the add array’s order for layout-critical placement. Put the extension ID in order when the position matters.
Extensions can provide an order index in their definition to influence the order in which they are rendered. This works like z-index in CSS—similarly, it is a way of setting relative order among elements that don’t officially know about each other.
Administrators can also override the sort order using the order array alongside add/remove arrays in the slot configuration. The runtime automatically processes these configuration changes to determine the final order of extensions.
Feature flags
Extensions can be conditionally rendered based on feature flags. This allows you to gradually roll out new features or enable experimental functionality for specific implementations.
Declaring feature flags in routes.json:
{
"extensions": [
{
"name": "experimental-widget",
"component": "experimental",
"slot": "dashboard-slot",
"featureFlag": "experimental-feature"
}
]
}If the featureFlag property is specified, the extension will only render when that feature flag is enabled. Register the flag in the module’s featureFlags array in routes.json, or register it from code with registerFeatureFlag. Registered flags appear in the Implementer Tools with toggle switches, and implementers can enable them globally through app shell configuration.
Advanced slot rendering
The ExtensionSlot component supports advanced rendering patterns for customizing how extensions are displayed.
Filtering extensions with the select prop:
You can filter which extensions are rendered using the select prop, which accepts a function that receives the array of assigned extensions and returns a filtered array:
<ExtensionSlot
name="patient-header-slot"
select={(extensions) => extensions.filter(ext => ext.meta?.priority === 'high')}
/>Custom rendering with function children:
You can customize how each extension is rendered by passing a function as children. The function receives the assigned extension object. If your extensions need state, close over that state from your component and pass it to <Extension state={...} />:
const slotState = { patientUuid };
<ExtensionSlot name="dashboard-slot">
{(extension) => (
<div className={`widget widget-${extension.meta?.size || 'medium'}`}>
<Extension state={slotState} />
</div>
)}
</ExtensionSlot>The extension object (of type AssignedExtension) includes properties like name, id, meta, moduleName, config, online, offline, and featureFlag, allowing you to create sophisticated layouts based on extension metadata.
Accessing extension context:
Each extension receives an _extensionContext prop containing:
extensionId: The unique identifier for this extension instanceextensionSlotName: The name of the slot this extension is rendered inextensionSlotModuleName: The module that owns the slotextensionModuleName: The module that provides the extension
This context is useful for debugging, logging, or conditional logic that depends on where an extension is rendered.
Dynamic slot management
While most extensions are attached to slots declaratively via routes.json or administrator configuration, you can also manage slot attachments programmatically at runtime.
Attaching extensions dynamically:
import { attach } from "@openmrs/esm-framework";
// Attach an extension to a slot at runtime
attach("patient-header-slot", "vitals-widget");
// Attach the same extension multiple times with different IDs
attach("patient-header-slot", "vitals-widget#instance1");
attach("patient-header-slot", "vitals-widget#instance2");Extension IDs can include an optional # suffix to distinguish multiple instances of the same extension within a slot. For example, "vitals-widget#primary" and "vitals-widget#secondary" are two distinct instances of the "vitals-widget" extension. When configuring, removing, or ordering an instance, use the full ID including the # suffix.
This is useful for:
- Dynamic slots that are created at runtime (e.g., workspace slots)
- Implementation-specific logic that conditionally shows extensions
- Temporary extensions that should only appear under certain conditions
- Rendering the same extension multiple times in the same slot with different configurations
Detaching extensions:
Deprecated: The detach and detachAll functions are deprecated. Extension attachments should be considered declarative. Use configuration (remove array) or avoid attaching extensions in the first place rather than detaching them at runtime.
import { detach, detachAll } from "@openmrs/esm-framework";
// Detach a specific extension from a slot (deprecated)
detach("patient-header-slot", "vitals-widget");
// Detach all extensions from a slot (deprecated)
detachAll("patient-header-slot");Querying assigned extensions:
You can programmatically get the list of extensions assigned to a slot:
import { getAssignedExtensions } from "@openmrs/esm-framework";
const extensions = getAssignedExtensions("patient-header-slot");
// Returns an array of AssignedExtension objects with properties:
// - id: unique extension instance ID (may include # suffix)
// - name: extension name
// - moduleName: the module that provides the extension
// - meta: extension metadata object
// - config: extension configuration (null until slot is mounted)
// - online, offline: connectivity flags
// - featureFlag: optional feature flag nameThis is useful for building custom slot implementations or debugging extension assignments.
How slots and extensions register themselves
Behind the scenes, slots call registerExtensionSlot(moduleName, slotName) when they mount (the React-friendly <ExtensionSlot> helper does this for you). This tells the runtime which module owns the slot and seeds the internal store (registerExtensionSlot lives in @openmrs/esm-extensions).
Extensions declared in routes.json are registered via registerExtension. (The setupOpenMRS() approach from Core v4 and earlier is no longer supported in Core v5+.) Attaching slot/slots fields in the same definition is the declarative way to populate attachedIds; calling attach(slotName, extensionId) gives you imperative control (useful for dynamic slots or implementation-specific tweaks). Once registered/attached, the runtime merges administrator config, feature flags, privileges, and online/offline constraints before rendering the assigned extensions.
Meta and slot APIs
Slots can read meta through useExtensionSlotMeta() and use it to adjust layouts—dashboards read column requests, patient headers read badge sizes, etc. meta is definition metadata, while extension configuration is exposed separately through the assigned extension’s config value and useConfig() inside the extension.
For hooks into slot state if the default helpers are not enough, consult registerExtensionSlot, renderExtension, getAssignedExtensions, and useExtensionSlotMeta from @openmrs/esm-framework, so you understand how the stores, configs, and connectivity checks interact.
Troubleshooting
If your extension isn’t showing up:
- Confirm that the module appears in
/openmrs/spa/routes.registry.jsonand that theextensionsentry is present. - Double-check
routes.json— thecomponentname must match the named export fromsrc/index.ts. - Confirm the target slot exists and is rendered (look for
<ExtensionSlot name="..." />in the host module). - Check route-level
privileges,displayExpression,featureFlag, and the extension definition’sonline/offlineflags. Also checkDisplay conditionsconfiguration for privilege or expression overrides. - Unless you explicitly need runtime control, bind the slot in
routes.jsonrather than usingattach. - Slot configuration can add, remove, configure, or reorder entries through the slot’s
add,remove,configure, andorderarrays.
Worked example
-
Export the extension component in
src/index.ts:import { getSyncLifecycle } from "@openmrs/esm-framework"; import { createDashboardLink } from "./createDashboardLink"; import { dashboardMeta } from "./dashboard.meta"; const moduleName = "@openmrs/esm-patient-list-management-app"; const options = { featureName: "patient list", moduleName, }; export const patientListDashboardLink = getSyncLifecycle(createDashboardLink(dashboardMeta), options);Note: The
getSyncLifecyclewrapper is required to convert your React component into an extension-compatible lifecycle. The options object must includefeatureNameandmoduleName. -
Add an entry to
routes.json:{ "extensions": [ { "name": "patient-lists-dashboard-link", "component": "patientListDashboardLink", "slot": "homepage-dashboard-slot", "meta": { "name": "patient-lists", "slot": "patient-lists-dashboard-slot", "title": "Patient lists" } } ] } -
Ensure a host renders the target slot, e.g.,
<ExtensionSlot name="homepage-dashboard-slot" />. -
Admins can later tweak the slot’s
orderarray or useadd/removearrays via the configuration system to control which extensions appear and in what order. -
To reuse the same extension in another slot, either call
attach("another-slot", "patient-lists-dashboard-link")at runtime, or add the extension to the other slot’saddarray in the configuration.
Additional Resources
Short introductory videos:
- OpenMRS Frontend 3 Extension System 1 - Basics
- OpenMRS Frontend 3 Extension System 2 - State and Meta
- Introductory presentation: Quick Guide to Slots
For a terse technical description of the extension system, see the Extensions RFC .
Workshop
A live workshop was hosted on Zoom, providing a comprehensive introduction to the extension system, as well as practical problems. Recordings and materials are available below.
- Part 1: About our Frontend Module Architecture & How to Use Extensions
- Part 2: Practical Session on our MFE Architecture & How to Use Extensions
How the extension system works
For the extension system to work four things exist:
- A generic component model with a defined lifecycle and loading mechanism
- A way to define where extensions should be placed (so called “slot”)
- A way to define an extension coupling it to (1)
- A configuration for assigning available extensions from (3) to slots (2)
Let’s explore these four things in depth.
Behind the Scenes
For (1), extensions are implemented using single-spa parcels .
For (2) you can use the registerExtensionSlot() function together with renderExtension(). For React modules, the ExtensionSlot and Extension helpers are exported from @openmrs/esm-framework.
For (3), you define extensions in your application’s routes.json file (recommended for Core v5+). An example:
{
"extensions": [
{
"name": "foo",
"component": "fooComponent"
}
]
}Note: fooComponent is the name of the export defined in src/index.ts.
As a shorthand for (4), you can specify a target slot via the slot property in the extension definition. Alternatively, you can attach extensions programmatically using attach:
// attaches an extension "foo" to a slot "foo-slot"
attach("foo-slot", "foo");Generally, slot assignment is done at initialization time as a default (via the slot property in routes.json), or explicitly via administrator configuration. The exception is “dynamic” slots that are created at runtime, such as workspace slots in the patient chart module.
Extensions and Slots
An extension can be in any of the following four states with respect to an extension slot:
- attached: Set via code using
attach()(or declaratively via theslot/slotsproperty inroutes.json). Note:detach()is deprecated. - configured: Set via administrator configuration using the
addandremovearrays - assigned: Computed by merging attached and configured extensions, then filtering based on privileges, feature flags, and connectivity (
online/offlineflags) - connected: (Deprecated term) This is the same as “assigned” after filtering—extensions that are actually rendered
The runtime uses the “assigned” state to determine which extensions to render. The filtering happens automatically based on user privileges, enabled feature flags, and connectivity state.
Rendering
Extensions are rendered by following their exported lifecycle functions. The getAsyncLifecycle and getSyncLifecycle functions from @openmrs/esm-framework are convenience layers that export these lifecycle functions wired together with single-spa-react. Use getAsyncLifecycle for components that need to be lazy-loaded, and getSyncLifecycle for components that are already available synchronously.
In a nutshell:
- When the component should be rendered, the
loadfunction is evaluated. If it returns aPromise(via an asynchronously loadedimport()), the system waits for the component to be available. - The component is wrapped with lifecycle functions provided by
single-spa-react. - The lifecycle functions (
bootstrap,mount,unmount, andupdate) are exported and managed by Single-SPA.
These lifecycle functions are not magic - theoretically you could write them on your own, however, since the single-spa ecosystem already provides convenience wrappers such as single-spa-react for many frameworks we don’t recommend it.
Before rendering, two additional factors are evaluated:
- Connectivity mode: Whether the extension should render based on the current online/offline state
- Component props: What data and services should be passed to the rendered component
Connectivity mode is determined by checking the browser’s connectivity status via isOnline() from @openmrs/esm-utils. This function first checks if window.offlineEnabled is true; if not, it assumes the app is always online. If offline mode is enabled, it then checks navigator.onLine to determine the actual connectivity state.
The extension’s online and offline flags control whether it renders in each mode:
- If
online: false, the extension will not render when the browser is online - If
offline: true, the extension will render when the browser is offline - By default, extensions render online (
online: true) and do not render offline (offline: false)
Note: While the type system allows online and offline to be objects, this feature is not currently implemented in the rendering logic. Use boolean values.
Component props include:
_meta: The extension’s metadata object_extensionContext: Information about the extension and its slot (extension ID, slot name, module names)- Any additional props passed via the
ExtensionSlotstateprop, or via<Extension state={...} />when custom children are used