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 and exported from src/index.ts. The module’s routes.json file names those exports and declares where they should appear. During distro assembly, spa-assemble-config.json produces both an import map and a routes registry. The app shell uses the routes registry to discover pages and extensions, then uses the import map to fetch the module code when one of those pages or extensions needs to render.
If you are creating a new module rather than trying to understand an existing one, start with the Creating a frontend module recipe. This overview explains the moving parts; the recipe walks through the published npm create @openmrs/o3-app@latest workflow.
Critical concepts (don’t skip these)
Here are four 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. Distro assembly generates an import map for code URLs and a routes registry from each module’sroutes.json. If a module is missing from either one, its UI will not render correctly. - Registration flow (why pages/extensions show up):
routes.jsondeclares pages, extensions, modals, workspaces, feature flags, and runtime conditions. The app shell registers that metadata first. Later, when a matching route or slot renders, it loads the named lifecycle export fromsrc/index.ts. - Version tags (why your change isn’t visible):
latestandnextare different. Your distro only loads the version tag you put inspa-assemble-config.json. - Runtime conditions (why something loads but still does not render):
routes.jsoncan declare backend dependencies, privileges, feature flags, and online/offline behavior. A module can be present in the import map and still hide an extension if one of those conditions is not met.
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 uses two distro artifacts. It reads the routes registry to know which UI contributions exist and the import map to know where each module’s JavaScript bundle lives.
- Modules connect metadata to code in two places:
routes.jsondeclares pages, extensions, modals, workspaces, feature flags, backend requirements, privileges, and offline/online conditions.src/index.tsexports the lifecycle functions named byroutes.json, plus optional startup setup such as config schemas, breadcrumbs, and translations.
- 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 for code URLs. It maps module names to bundle URLs and controls versions. The routes registry is the source of truth for what the module contributes to the UI.
- Local development can override both artifacts.
openmrs developand Devtools can point a module at your local dev server and use that module’s localroutes.jsonwithout changing the distro’sspa-assemble-config.json.
Frontend modules in O3 are typically built using React. Because we use single-spa 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 . 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
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
- package.json
- yarn.lock
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 are described in this spa-assemble-config.json 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 app-shell frontend module should 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
src/routes.jsonfile that defines the module’s static metadata - Named lifecycle exports in
src/index.tsfor every component referenced fromroutes.json - A
startupAppfunction when the module needs load-time setup such as config schemas, breadcrumbs, or other framework registration
Manifest file (package.json)
Each frontend module has a root-level package.json file that defines its dependencies and metadata. Below is a shortened snippet in the shape generated by npm create @openmrs/o3-app@latest:
{
"name": "@openmrs/esm-active-prescriptions-app",
"version": "1.0.0",
"license": "MPL-2.0",
"description": "An OpenMRS frontend module for active prescriptions",
"browser": "dist/openmrs-esm-active-prescriptions.js",
"main": "src/index.ts",
"source": true,
"scripts": {
"start": "openmrs develop",
"build": "rspack --mode production",
"build:development": "rspack --mode development",
"analyze": "rspack --mode=production --env.analyze=true",
"serve": "rspack serve --mode=development",
"lint": "eslint src --ext ts,tsx",
"typescript": "tsc",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest watch",
"coverage": "vitest run --coverage --passWithNoTests",
"extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.extension.tsx' 'src/**/*.modal.tsx' 'src/index.ts' --config ./tools/i18next-parser.config.js",
"prettier": "prettier --config prettier.config.js --write \"src/**/*.{ts,tsx,css,scss}\"",
"postinstall": "husky install"
},
"dependencies": {
"@carbon/react": "^1.83.0"
},
"peerDependencies": {
"@carbon/react": "1.x",
"@openmrs/esm-framework": "*",
"dayjs": "1.x",
"i18next": "25.x",
"react": "18.x",
"react-dom": "18.x",
"react-i18next": "16.x",
"react-router-dom": "6.x",
"swr": "2.x"
},
"devDependencies": {
"openmrs": "next",
"@openmrs/esm-framework": "next",
"@openmrs/rspack-config": "next",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.5.2",
"@rspack/cli": "^1.7.10",
"@rspack/core": "^1.7.10",
"jsdom": "^28.0.0",
"typescript": "^5.9.3",
"vitest": "^4.1.2"
},
"packageManager": "yarn@4.10.3"
}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 OpenMRS runtime packages are usually peer dependencies for consumers and
nextdev dependencies for local development. - The Rspack config and CLI packages are dev dependencies because they are build-time tooling, not runtime modules loaded by the app shell.
- Current React frontend modules use Rspack and Vitest by default. Older modules may still have Webpack or Jest scripts while they are being migrated.
The application entry point (index.ts)
Frontend modules define their entry point in src/index.ts.
import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
import ActivePrescriptionsSummary from "./active-prescriptions-summary.component";
import { moduleName } from "./constants";
export const importTranslation = require.context("../translations", false, /.json$/, "lazy");
const options = {
featureName: "active-prescriptions",
moduleName,
};
export function startupApp() {
defineConfigSchema(moduleName, configSchema);
}
export const root = getAsyncLifecycle(() => import("./root.component"), options);
export const activePrescriptionsSummary = getSyncLifecycle(ActivePrescriptionsSummary, 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 lifecycle functions,
rootandactivePrescriptionsSummary. These names must match the component names declared inroutes.json. The app shell calls them when the corresponding page or extension needs to render. - 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. Modules that need breadcrumbs or other framework registration can do that instartupApptoo.
The startupApp function
Many frontend modules define a function named startupApp. This function performs setup that should happen once, immediately before the module’s first page, extension, modal, or workspace lifecycle is loaded. The startupApp function is where we commonly:
- define the module’s configuration schema
- register breadcrumbs
- run other load-time setup that must happen before the module’s exported lifecycles are used
Page and extension registration itself comes from routes.json, not from startupApp. If a module does not need load-time setup, startupApp can be omitted.
The importTranslation function
This is required when the module provides translations. 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
modals,workspaces,workspaceGroups, andfeatureFlagsthat the frontend module provides - The
backendDependenciesthat the frontend module requires. This object 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 .
Before shipping a new page or extension, check whether routes.json should also declare:
backendDependenciesoroptionalBackendDependenciesfor required backend modules.privilegesfor role-based access to an extension.featureFlagsand per-extensionfeatureFlagvalues for work that should be hidden until enabled.onlineandofflineflags for components that should render differently when offline support is enabled.
For deeper details on extension metadata, see the Extension system guide. For distribution-level backend and config behavior, see the Configuration overview.
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 and routes registry from the packages in
spa-assemble-config.json. - The app shell loads both artifacts at runtime.
- The app shell registers route metadata from the routes registry.
- Routes are matched and slots are filled from that registered metadata.
- The app shell loads your module’s bundle from the import map the first time it needs one of your lifecycle exports.
- Your module’s
startupAppruns once, then the requested page, extension, modal, or workspace lifecycle runs.
If your module is not 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 module in the routes registry?
Open
/openmrs/spa/routes.registry.jsonand confirm the module has the page, extension, modal, workspace, or feature flag metadata 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?
Ensure
src/index.tsexports the named pages/extensions you declared inroutes.json. - Do route names match export names?
If
routes.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 import map or route metadata in dev? Check Devtools overrides and localStorage. A stale import map or route 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 and route 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.