Overview
Frontend modules are the fundamental building blocks for creating applications in O3. They are self-contained pieces of functionality that can be loaded by the app shell. For example, you could have a frontend module that handles rendering concerns related to vitals and biometrics. This module could include the following functionality:
- A component that displays a tabular overview of a patient's vitals and biometrics
- A component that displays chart visualizations of a patient's vitals and biometrics
- A form that allows a user to record a new set of vitals and biometrics readings
- A component that displays a patient's most recent vitals and biometrics readings in the patient's chart header
These components would be defined in the frontend module's src directory. Components exported from src/index.ts are registered with the app shell and can be used in the application. They would get displayed on the screen when the user navigates to the appropriate pages. The app shell decides what to load based on your distribution's import map (generated from spa-assemble-config.json).
Critical concepts (don’t skip these)
Here are three high‑impact concepts that are easy to miss:
- Distribution wiring (what actually loads):
spa-assemble-config.jsondefines which frontend modules and versions are included in your distro. That file generates the import map, and the app shell only loads what the import map contains. If a module isn’t in the import map, it won’t load. - Registration flow (why pages/extensions show up):
src/index.tsexports androutes.jsonwork together. The app shell readsroutes.jsonand the named exports fromindex.ts, then registers pages and extensions. If these don’t line up, nothing renders. - Version tags (why your change isn’t visible):
latestandnextare different. Your distro only loads the version tag you put inspa-assemble-config.json.
Mental model (how frontend modules really work)
If you’re new to O3, this is the short mental model to keep in mind:
- A frontend module is an npm package. Each module is published independently and has its own
package.json,src/index.ts, androutes.json. - The app shell decides what to load. It reads your distribution’s import map (generated from
spa-assemble-config.json) and loads the modules listed there. - Modules declare what they provide in two places:
src/index.tsexports dynamic metadata (pages/extensions as named exports, plus startup logic).routes.jsondeclares static metadata (routes/pages, backend requirements, etc.).
- Pages and extensions are not the same thing.
- Pages are full routes (e.g.,
/patient/123/vitals). - Extensions are pluggable UI fragments that render into extension slots owned by other modules (e.g., a widget on the patient chart).
- Pages are full routes (e.g.,
- Slots live in the host UI. A host app exposes slots; other modules register extensions for those slots. That’s how O3 remains modular.
- The import map is the source of truth. It maps module names to URLs and controls versions. If a module isn’t in the import map, it doesn’t load.
- Local development overrides the import map. Devtools can swap a distro version for your local dev server without changing the distro’s
spa-assemble-config.json.
Frontend modules in O3 are typically built using React. Because we use single-spa (opens in a new tab) under the hood, other frameworks are possible, but they are less common. For example, the Form entry frontend module is written in Angular and wraps the Angular form engine (opens in a new tab). So in that sense, the app shell is framework-agnostic. In practice, the lower-level workings of the app shell have been abstracted so frontend engineers can focus on module behavior rather than the framework.
Frontend modules are typically organized into domain-specific repositories. For example, frontend modules concerning the
management of patients exist in the Patient Management (opens in a new tab)
monorepo. Some modules live in monorepos, while others are standalone repos, depending on scope. Regardless of layout,
each module is built and published as its own npm package so it can be referenced in your distribution's import map. In
monorepos, you'll typically have a packages directory at the root that contains the individual frontend modules. For
example, the Patient Management monorepo has the following structure:
Quick anatomy (at a glance):
package.json- module metadata and dependenciessrc/index.ts- module entry pointroutes.json- static route metadata (what pages/extensions exist and their routes)config-schema.ts- where the module's configuration properties live
- config-schema.ts
- declarations.d.ts
- index.ts
- routes.json
Each frontend module is structured as an independent npm package with:
- Its own
package.jsonfile (separate from the root-levelpackage.jsonfile). This file defines the module's dependencies and metadata. - A
srcdirectory that contains the source code for the frontend module. That directory contains the following important files:- A
config-schema.tsfile that defines the module's configuration schema - A
declarations.d.tsfile that defines the module's TypeScript declarations - An
index.tsfile that defines the module's entry point - A
routes.jsonfile that defines the module's static metadata (routes, pages, backend requirements)
- A
Frontend modules are alternatively referred to as microfrontends in O3. They have the following characteristics:
- They follow the
esm-naming convention, even though they are loaded at runtime via Module Federation bundles. - They have a descriptive middle section in their name describing the module's functionality. For example,
esm-patient-search-appis a module that handles searching for patients. - They may have an
-appsuffix in their name, but this is not required.
Versions of frontend modules explained
Each frontend module is an npm package, typically with a name ending in -app. OpenMRS ESMs are released with three different tags:
"next", "latest", and a version number. Here's what each tag means:
- 🔴⚠️🚧 "next" = Pre-release, in development. Newest, cutting edge, still under construction. "next" always refers to the most recent but not-yet-released version of an ESM (e.g., 3.2.1-pre.1067). Versions labeled "next" are not recommended for production use as they are considered unstable works-in-progress and have often not undergone integration testing.
- 🟡 "latest" = Most recent release. "latest" always refers to the most recent released version of an ESM (e.g., 3.2.0). While you can use the "latest" version of any ESM, you have more control by specifying the exact version number of each ESM you use.
- 🟢✅ vX.X.X = A specific version. A version number always refers to a specific build of an ESM. For example, 3.2.0 or 3.2.1-pre.1067 are both specific versions of the @openmrs/esm-api ESM, though the latter is a pre-release version.
O3 distributions consist of a set of frontend modules that are shipped together. For example, the frontend modules shipped with the community demo reference application (opens in a new tab) are described in this spa-assemble-config.json (opens in a new tab) file. This is where you decide which versions (next, latest, or a fixed version) end up in your import map.
In other words: the version tag you choose in spa-assemble-config.json becomes the version your app shell loads.
Anatomy of a frontend module
Every frontend module must have:
- A
package.jsonmanifest file that defines the module's dependencies and metadata - A
src/index.tsfile that defines the module's entry point - A
startupAppfunction that defines the module's dynamic metadata - A
src/routes.jsonfile that defines the module's static metadata
Manifest file (package.json)
Each frontend module has a root-level package.json file that defines its dependencies and metadata. Below is a snippet of the package.json file from the form builder frontend module:
{
"name": "@openmrs/esm-form-builder-app",
"version": "2.0.1",
"license": "MPL-2.0",
"description": "OpenMRS ESM Form Builder App",
"browser": "dist/openmrs-esm-form-builder-app.js",
"main": "src/index.ts",
"source": true,
"scripts": {
"start": "openmrs develop",
"serve": "webpack serve --mode=development",
"build": "webpack --mode production",
"analyze": "webpack --mode=production --env.analyze=true",
"lint": "TIMING=1 eslint src --ext js,jsx,ts,tsx",
"prettier": "prettier --write \"src/**/*.{ts,tsx}\"",
"typescript": "tsc",
"test": "jest --config jest.config.js",
"test-e2e": "playwright test",
"verify": "turbo lint typescript coverage",
"coverage": "yarn test --coverage --passWithNoTests",
"postinstall": "husky install",
"extract-translations": "i18next 'src/**/*.component.tsx' --config ./i18next-parser.config.js",
"ci:bump-form-engine-lib": "yarn up @openmrs/openmrs-form-engine-lib@next"
}
}Some key things to note from looking at this file include:
- The
nameproperty which defines the name of the module. This property is used as the module’s unique identifier in the import map. - The
browserproperty which points to the entry point of the built bundle that gets served (what the app shell loads at runtime). - The
mainproperty which defines the entry point of the frontend module’s source code, which is typicallysrc/index.ts.
The application entry point (index.ts)
Frontend modules define their entry point in src/index.ts.
import { defineConfigSchema, getSyncLifecycle, registerBreadcrumbs } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
import rootComponent from "./root.component";
import systemAdministrationFormBuilderCardLinkComponent from "./form-builder-admin-card-link.component";
const moduleName = "@openmrs/esm-form-builder-app";
const options = {
featureName: "form-builder",
moduleName,
};
export const importTranslation = require.context("../translations", true, /.json$/, "lazy");
export function startupApp() {
defineConfigSchema(moduleName, configSchema);
registerBreadcrumbs([
{
path: `${window.spaBase}/form-builder`,
title: "Form Builder",
parent: `${window.spaBase}/home`,
},
{
path: `${window.spaBase}/form-builder/new`,
title: "Form Editor",
parent: `${window.spaBase}/form-builder`,
},
{
path: `${window.spaBase}/form-builder/edit/:uuid`,
title: "Form Editor",
parent: `${window.spaBase}/form-builder`,
},
]);
}
export const root = getSyncLifecycle(rootComponent, options);
export const systemAdministrationFormBuilderCardLink = getSyncLifecycle(
(systemAdministrationFormBuilderCardLinkComponent),
options
);This file is the entry point of the frontend module. It is the first file that gets executed when the frontend module gets loaded. It is responsible for setting up the frontend module and exporting the module’s configuration. Specifically, in this example:
- It exports an
importTranslationfunction which is used to load the module’s translations. - It also exports two named exports,
rootandsystemAdministrationFormBuilderCardLink. These are named exports for a page and an extension, respectively. They are used to tell the app shell how to load the frontend module’s content. - It also exports a
startupAppfunction which is used to set up the frontend module. In this case, the frontend module's configuration schema is defined here, as well as the breadcrumbs for the module.
The startupApp function
Each frontend module defines a function named startupApp. This function performs any setup that should occur at the time the module gets loaded. The startupApp function is where we:
- define the module's configuration schema
- register breadcrumbs
- connect dynamic metadata (what the module exports) with the static metadata in
routes.json
The importTranslation function
This is required for translations to work. It tells the frontend application how to load translation strings. Note that the first argument to require.context is a directory, ../translations. That directory must exist at that location relative to the index.ts file.
Static metadata in routes.json
The routes.json file is used to set up the frontend module's static metadata. These include:
- The
pagesthat the frontend module provides - The
extensionsthat the frontend module provides - The
backenddependencies that frontend module requires. This is an object that tells the frontend application what OpenMRS server modules the frontend module depends on, and what versions. If these dependencies are not met, administrators will be alerted
The structure of this static file is dictated by the OpenMRS Routes standard JSON schema (opens in a new tab).
Extensions and extension slots (how modules plug into each other)
O3’s UI is composed by plugging extensions into slots. Here’s the flow:
- A host module declares an extension slot in its UI (for example, a “Patient chart widgets” slot).
- Other modules register extensions for that slot (for example, a “Vitals widget” extension).
- The app shell resolves everything and renders extensions into the slots at runtime.
This lets you add, remove, or replace UI fragments without changing the host module. It’s one of the biggest reasons O3 can remain modular.
Extensions can also be enabled, disabled, and configured via the Configuration system guide.
From source code to a running screen (end‑to‑end lifecycle)
To make this concrete, here is the full lifecycle:
- You build and publish a module to npm.
- You add it to
spa-assemble-config.json(or update its version tag). - The distro build generates an import map from
spa-assemble-config.json. - The app shell loads the import map at runtime.
- The app shell loads your module from the URL in the import map.
- Your module’s
startupAppruns, registering pages/extensions and config schema. - Routes are matched (pages render) and slots are filled (extensions render).
If your module isn’t showing up, the failure is almost always in one of these steps.
Troubleshooting when a module doesn’t show up
Use this checklist to quickly locate the failure:
- Is the module in the import map?
Open/openmrs/spa/importmap.jsonand confirm the module name exists and points to the URL you expect. - Is the URL reachable?
Open the URL from the import map in your browser. You should see a JS bundle, not a 404/HTML page. - Is the module exporting what the app shell expects?
Ensuresrc/index.tsexports the named pages/extensions you declared inroutes.json. - Do route names match export names?
Ifroutes.jsondeclares a page component"root", yourindex.tsmust exportroot. - Are you targeting the right extension slot?
Make sure the slot name in your extension registration matches the host UI’s slot name exactly. - Are you overriding the import map in dev?
Check Devtools overrides and localStorage. A stale override can mask your changes.
Key related concepts (worth knowing early)
If you’re building or debugging modules, these concepts will save you time:
- Configuration schema –
config-schema.tsdefines the module’s configurable options. The app shell loads and merges config at runtime, so changes here affect how implementers can tailor behavior. Learn more in the Configuration system guide. - Translations – Modules provide translations via
importTranslationinsrc/index.ts, usually loading JSON files from atranslations/directory. If this is missing or misconfigured, strings won’t localize. - Sync vs async lifecycles –
getSyncLifecyclebundles code into the main chunk, whilegetAsyncLifecyclecode-splits and loads on demand. Use each appropriately for performance. See the Frontend modules testing guide for examples and the performance notes in Coding conventions. - Backend requirements –
routes.jsoncan declare backend module dependencies and versions. If they’re not met, O3 will warn administrators. - Devtools overrides – Local development usually relies on import map overrides. See Development.
- Versioning strategy –
latestis the most recent release,nextis pre‑release. Pin versions for stability in production, usenextfor testing and early access.