Overview
Significant changes to the architecture of the app shell related to how frontend modules get loaded were introduced in Core v5. If you haven't migrated your frontend modules to leverage these new capabilities, follow the Migration Guide for a in-depth walkthrough of how to do so.
Frontend modules are the basic building blocks of O3. In O3, frontend modules are self-contained pieces of functionality that can be loaded into the app shell. They are typically organized into domain-specific monorepos (opens in a new tab). For example, frontend modules concerning the management of patients exist in the Patient Management (opens in a new tab) monorepo.
In this structure, you'll typically have a packages
directory at the root of your monorepo. This packages directory will contain all of your frontend modules. For example, the patient management monorepo has the following structure:
- config-schema.ts
- declarations.d.ts
- index.ts
- routes.json
Each frontend module has its own package.json
file separate from the root-level package.json
file. This is because each frontend module is a separate NPM package. This means that each frontend module can have its own scripts and dependencies. Additionally, each frontend module has a src
directory that contains the source code for the frontend module. The src
directory also contains two important files from the perspective of the app shell - index.ts
and routes.json
. These two files define the dynamic and static metadata of the frontend module, respectively. They are discussed in more detail below.
Frontend modules are also referred to as microfrontends
in O3. They get shipped in the ES module format, which explains the esm-
prefix in their nomenclature. They may have an -app
suffix in their name, but this is not required. In O3, frontend modules typically consist of React components, but this is not a requirement. They can be written in any frontend framework that single-spa (opens in a new tab) supports. For example, the [Angular form engine] frontend module is written in Angular and is a wrapper around the AMPATH 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 in such a way that frontend engineers can focus on writing frontend modules without having to worry about the underlying framework.
Frontend modules get loaded into the application based on a special JSON file called an import map (opens in a new tab). More details about how frontend modules get loaded are discussed in the App shell and Module loading guide.

This diagram walks through what happens when the application gets executed. The app shell loads configuration files, the import map specifies where frontend modules get fetched from, and loads frontend modules.
Anatomy of a frontend module
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
name
property which defines the name of the module. This property is used as the module’s unique identifier in the import map. - The
browser
property which points to the entry point of the webpack bundle. - The
main
property 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 have their entry point (opens in a new tab) defined in src/index.ts
.
import {
defineConfigSchema,
getAsyncLifecycle,
registerBreadcrumbs,
} from "@openmrs/esm-framework";
import { configSchema } from "./config-schema";
const moduleName = "@openmrs/esm-form-builder-app";
const options = {
featureName: "form-builder",
moduleName,
};
export const importTranslation = require.context(
"../translations",
true,
/.json$/,
"lazy"
);
export const root = getAsyncLifecycle(
() => import("./root.component"),
options
);
export const systemAdministrationFormBuilderCardLink = getAsyncLifecycle(
() => import("./form-builder-admin-card-link.component"),
options
);
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`,
},
]);
}
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
importTranslation
function which is used to load the module’s translations. - It also exports two named exports,
root
andsystemAdministrationFormBuilderCardLink
. 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
startupApp
function which is used to set up the frontend module. In this case, the frontend module's configuration schema gets 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. It returns an object that communicates how the app shell should load the module. The startupApp function where
- define the module's configuration schema.
- register breadcrumbs.
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
extensions
that frontend module provides. - The
pages
that frontend module provides. - The
backend
dependencies 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).
Creating a page
To create a new page, you'll typically need to follow these steps:
-
Create a new React component that will be the page's entry point.
-
Add a named export for the page in the
index.ts
file. For example, here's how a page namedroot
is exported from theindex.ts
file:export const root = getAsyncLifecycle(() => import("./root"), options);
-
Add a page definition for the new page to the
routes.json
file'spages
array:{ "pages": [ { "component": "root", // maps to the named export in `index.ts` "route": "form-builder", "online": true, "offline": true } ] }