Core concepts
This section explains how the core pieces of O3 fit together. By the end, you should understand how O3 starts up and how modules are loaded and configured. The diagram below shows the main building blocks and how they relate.
Diagram notes (for clarity): spa-assemble-config.json is used during distro assembly to download frontend module
packages and produce the two runtime artifacts the app shell reads: importmap.json and routes.registry.json. Modules
do not load directly from npm at runtime. They load from bundle URLs in the import map, or from local dev server URLs when
import map and route overrides are active.
The major pieces in the O3 frontend architecture are:
- The app shell - the base layer that coordinates startup and runtime behavior.
- Frontend modules - composable UI building blocks.
- The import map - a JSON file that maps module names to module bundle URLs.
- The routes registry - a JSON file that tells the app shell which pages, extensions, modals, workspaces, and feature flags modules provide.
- The core framework - shared libraries and APIs used by frontend modules.
Where this lives in code
Two repositories are especially useful when you want to trace these concepts in real code. The
openmrs-esm-core monorepo contains the runtime and tooling:
- App shell:
packages/shell/esm-app-shell - Core framework (aggregated):
packages/framework/esm-framework - Framework libraries (individual packages):
packages/framework/* - Core frontend modules:
packages/apps/* - Map readers and dynamic loading:
packages/framework/esm-dynamic-loading - Route and extension registration:
packages/framework/esm-routes - Distro assembly tooling:
packages/tooling/openmrs
The openmrs-distro-referenceapplication repository
shows how a real distro uses that tooling. Its frontend/spa-assemble-config.json chooses frontend modules and versions,
while frontend/spa-build-config.json points the built app shell at the generated import map and routes registry.
App shell
The app shell is the host application that boots O3 and coordinates loading and runtime services. Introduced in RFC-26 , it handles:
- Startup: renders
index.html, loads the import map and routes registry, and wires module loading/routing. - Runtime services: defines breakpoints, subscriptions (modals, toasts, inline notifications), and offline support.
- Platform wiring: initializes global state and sets up the configuration and extension systems.
At startup, the app shell does not eagerly execute every frontend module. It sets up core UI services, reads
<script type="systemjs-importmap"> and <script type="openmrs-routes"> tags from index.html, registers the metadata
declared in the routes registry, loads configuration, and then starts single-spa. Module code is fetched later, when a
route, extension, modal, or workspace actually needs one of that module’s exported lifecycle functions.
Learn more: App shell deep dive
Frontend modules
Frontend modules are the units of functionality that make up the O3 UI. Think of each module as a mini‑app that owns a slice of the interface (for example, the patient chart or the primary navigation).
O3 treats these modules as microfrontends (popularized by single-spa ). The app shell reads
the routes registry generated from each selected module’s route metadata, registers the declared pages, extensions, modals,
workspaces, and feature flags, then loads the module’s exported lifecycle functions from the import map when UI code is
needed. If a module exports startupApp(), the app shell calls it once before loading that module’s component lifecycle
functions. Modules export lifecycle functions shaped for single-spa, following the interface defined in
RFC-26 .
In most distros, the import map and routes registry are generated during distro assembly from spa-assemble-config.json
and the selected modules’ packaged route metadata.
Extensions are plug‑in UI blocks that modules define for named slots.
Anatomy essentials (files you’ll touch most often):
package.json- module metadata, scripts, and dependenciessrc/index.ts- entry point that exports lifecycle functions and setup codesrc/routes.json- static route metadata for the app shellsrc/config-schema.ts- where the module’s configuration properties live
Frontend module code is loaded on demand by the app shell when it’s needed, which improves initial load performance.
Frontend modules can live in standalone repositories or in domain-focused monorepos when multiple modules share a domain.
For example, patient-management modules (registration, queues, appointments, bed management, etc.) are grouped in the
openmrs-esm-patient-management monorepo.
You control which modules are included in a distro by editing the distro’s spa-assemble-config.json file. To add your
own module, scaffold it with npm create @openmrs/o3-app@latest, publish it to npm under your own namespace, then add an
entry for it in spa-assemble-config.json. The
template app repo remains useful as a reference for the generated
module structure.
Next:
Import map
An import map is a browser specification for mapping module names to the URLs they should load from. Introduced in RFC-4 , it tells the app shell exactly where to fetch each frontend module. The app shell preloads the import map on startup and then uses it to resolve and load module bundles.
In practice, import maps are generated and served by a distro (not the app shell). For example, the import map for the
O3 community reference application is served at
/openmrs/spa/importmap.json, and the module/version list that generates it lives in
frontend/spa-assemble-config.json.
Most distros follow the same approach.
To view your distro’s import map in the browser, navigate to /openmrs/spa/importmap.json (or your distro’s SPA base
path), where you’ll see a simplified excerpt like:
{
"imports": {
"@openmrs/esm-home-app": "./openmrs-esm-home-app-4.1.1-pre.211/openmrs-esm-home-app.js",
"@openmrs/esm-login-app": "./openmrs-esm-login-app-4.3.2-pre.671/openmrs-esm-login-app.js",
"@openmrs/esm-primary-navigation-app": "./openmrs-esm-primary-navigation-app-4.3.2-pre.671/openmrs-esm-primary-navigation-app.js",
"@openmrs/esm-patient-chart-app": "./openmrs-esm-patient-chart-app-4.3.1-pre.1352/openmrs-esm-patient-chart-app.js"
}
}The keys in this object are module names (unique identifiers), and the values are bundle URLs. These URLs are often
relative paths in assembled distros, but local development overrides can point at absolute URLs such as a local dev server.
The app shell reads the import map at runtime when it needs to load a module’s code. Current O3 frontend modules are
loaded as Module Federation containers: the app shell appends a script tag for the bundle URL, then calls the container’s
init and get methods to load exported lifecycle functions. See the App shell deep dive for the
loading mechanics.
Learn more: Module loading
Routes registry
The routes registry is a JSON object that maps module names to the static metadata from those modules’ routes.json
files. The app shell reads it at startup to register:
- Pages and their routes
- Extensions and the slots they attach to
- Modals, workspaces, workspace groups, and feature flags
During distro assembly, openmrs assemble extracts each selected frontend module, reads its packaged routes.json, and
writes the combined registry to routes.registry.json. In local development, openmrs develop reads src/routes.json
directly and serves a generated registry for the running dev app.
This is intentionally separate from the import map. The import map answers “where is this module’s JavaScript bundle?” The routes registry answers “what pages, extensions, and other UI entry points does this module provide?” A module usually needs entries in both places before its UI can render.
How O3 loads modules (quick mental model)
- The app shell boots and preloads the import map and routes registry.
- It reads the routes registry and registers the declared pages, extensions, modals, workspaces, and feature flags.
- When a route, extension, modal, or workspace needs code, it resolves that module’s URL from the import map and loads its exported lifecycle functions.
- The router mounts pages as users navigate, and extension slots mount extensions when their host UI renders.
If you need to change…
- Which modules and versions are included in a distro → edit the distro’s
spa-assemble-config.json. - Which pages or extensions a module provides → edit the module’s
src/routes.json. - How modules load globally → app shell and dynamic-loading behavior.
- What a module does after it loads → the module’s own repo (UI, lifecycle exports, config, and business logic).
Core framework
The O3 framework is the shared runtime library that frontend modules build on. It is published as
@openmrs/esm-framework, but in the source repo it is made of
many focused packages under packages/framework/*. The aggregate package re-exports the public APIs from those packages
so modules can usually import from one place:
import { openmrsFetch, useConfig, ExtensionSlot, showToast } from "@openmrs/esm-framework";Think of the framework as the contract between the app shell and frontend modules. The app shell decides which modules are available and when their code should load. The framework gives those modules the APIs they use after they are loaded.
The most important pieces are:
- Backend and session APIs:
openmrsFetch, REST base URLs, backend dependency checks, current user/session helpers, patient and visit utilities, and event subscriptions. - Runtime metadata systems: configuration schemas, extension registration, extension slots, feature flags, global stores, app context, and expression evaluation.
- Rendering and lifecycle helpers: React hooks,
ExtensionandExtensionSlotcomponents, lifecycle helpers such asgetSyncLifecycleandgetAsyncLifecycle, and utilities that bridge React components into single-spa lifecycles. - Shared UX services: styleguide components, navigation and breadcrumbs, modals, workspaces, toasts, snackbars, notifications, translations, and offline support.
For module authors, the rule of thumb is: use the framework for cross-cutting O3 behavior instead of inventing local
versions of fetch wrappers, global state, extension wiring, config loading, or notification UI. If you need to change how
those shared systems behave globally, that usually belongs in openmrs-esm-core, not in an individual frontend module.
Learn more: Framework API reference
Next steps
Ready to build? Continue to Frontend modules to see how modules are structured and loaded.