Port a Frontend Plugin from frontend-plugin-framework to frontend-base#
This how-to walks through porting an existing pair of plugins to frontend-base:
A frontend plugin (an npm package full of React components plugged in via
env.config.jsx), andA Tutor plugin (a Python module that registers the frontend plugin via
tutormfe.hooks.PLUGIN_SLOTSand installs it into each MFE’s image).
It assumes you understand the legacy stack and want to land on
@openedx/frontend-base and tutor-mfe’s frontend-base site support. It
is not a tutorial on either. Refer to the following for more information:
frontend-template-application (frontend-base branch) - the reference implementation of a frontend-base
App, worth reading alongside the steps belowfrontend-base-compat (escape hatch for plugins you can’t port yet)
Understand the architectural shift#
In the legacy world, each MFE is effectively an independent single-page
application: it builds, deploys, and ships as its own Docker image, with its
own copy of shared dependencies, header, and footer. It reads its own
env.config.jsx to discover plugin slot operations from the npm packages
installed in that image. Composition across these silos is bolted on after the
fact: the Tutor plugin’s job is to (a) install the right npm packages into the
right MFE images, and (b) inject the slot operations into each MFE’s
env.config.jsx.
frontend-base is the reference implementation of the OEP-65 shell: a
build-time host that composes independently-developed apps into a single
site, so they share one set of dependencies and render as one page rather
than as a collection of separate deployments. Instead of a per-MFE
env.config.jsx, a site config (site.config.build.tsx for production,
site.config.dev.tsx for development) imports the App objects the site
composes and registers them. An App is a first-class, self-contained unit
of composition: it carries its own routes, providers, config, and the slot
operations that place widgets into named slots - its own, or those of
other apps or the shell. Composition is thus part of the architecture, not an
add-on layered over otherwise siloed frontends.
Some practical consequences:
@edx/frontend-platformis replaced by@openedx/frontend-base.A plugin no longer needs to be installed “into” the learner-dashboard image; it is installed into the site’s npm workspace once, as an
App.An
Appcan target any number of slots across any number of frontend apps from one place.Shared dependencies: an
Apppeer-depends on the shell’s single copy of React, Paragon, and@openedx/frontend-baserather than bundling its own. Keeping those version ranges compatible with the shell and the other composed apps is now part of the work.Navigating between apps within the same site no longer triggers a full page refresh.
Port the frontend plugin (npm package)#
This is the bulk of the work: turning the npm package that held your React
components into a self-contained frontend-base App. The steps below rewrite
its imports to point at @openedx/frontend-base, move the slot wiring out of
the site operator’s env.config.jsx and into the package itself as an
App, expose that App as the package’s default export, and update the
build so it produces plain, pre-built output the shell can consume. Work
through them in order; each assumes the previous one is done.
Rewrite component imports#
The first, most mechanical step is to point your component code at
frontend-base. @openedx/frontend-base re-exports the runtime surface that
used to be spread across @openedx/frontend-plugin-framework and
@edx/frontend-platform (including its /auth and /i18n
sub-modules), so most imports change only in the package they come from. The
table below lists the substitutions you’ll hit most often:
Legacy |
frontend-base |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Note the case change on config keys: legacy getConfig() exposed
UPPER_SNAKE_CASE; getSiteConfig() exposes the underlying camelCase
object directly. Site-specific knobs that don’t have a curated mapping live
under getSiteConfig().commonAppConfig (or under getAppConfig(appId) for
App-scoped settings declared on the App’s config).
Paragon imports (@openedx/paragon, @openedx/paragon/icons) don’t change.
Replace the slot-config shape with a frontend-base App#
Legacy plugins typically ship a plugin.jsx (the component) plus an
index.jsx that just re-exports it. Site operators write the slot wiring
themselves in env.config.jsx.
In frontend-base, the slot wiring is part of the package. Add an
app.tsx (or app.jsx) like:
import { WidgetOperationTypes } from '@openedx/frontend-base';
import MyWidget from './MyWidget';
const app = {
appId: 'org.example.frontend.app.myPlugin',
slots: [
{
slotId: 'org.openedx.frontend.slot.learnerDashboard.courseList.v1',
id: 'org.example.frontend.widget.myPlugin.courseList.v1',
op: WidgetOperationTypes.REPLACE,
relatedId: 'defaultContent',
component: MyWidget,
},
],
};
export default app;
Each entry in a slot’s slots array is a single operation, and its op
field says what that operation does to the slot: add a widget, remove the
default one, replace it, and so on. This is the frontend-base successor to
FPF’s PLUGIN_OPERATIONS, but the two don’t map one-to-one. FPF leaned on a
handful of operations whose exact effect depended on surrounding config (for
instance, whether keepDefault was set); frontend-base breaks those apart
into explicit, self-describing ops, so a behavior that used to be implied by
config is now named directly on the operation.
Use the table below to translate each of your existing operations. The middle
column gives the WidgetOperationTypes value (or, for a couple of cases,
another operation type) to reach for, and the Notes column flags where the
mapping is not a straight rename:
Legacy |
frontend-base |
Notes |
|---|---|---|
|
|
The new API distinguishes positions explicitly. |
|
|
iframe widgets are first-class. |
|
|
|
|
|
In FPF, |
|
|
Less ergonomic; consider whether you really need it. |
|
|
There is no blanket equivalent, but the default widget of a
frontend-base slot can be built to consume structured data passed via a
|
Slot id changes are also mechanical but not automatic. The mapping is
documented per-MFE in frontend-base-compat’s src/mappings/slotMaps/
and in each frontend-base app’s src/slots/ README. Two common ones:
course_list_slot->org.openedx.frontend.slot.learnerDashboard.courseList.v1org.openedx.frontend.learner_dashboard.course_list.v1(legacy reverse-DNS) -> same as above
For widget ids inside relatedId: the legacy default_contents becomes
defaultContent.
Update the package entry point#
With the App defined, the package’s entry point (index.ts /
index.js) becomes a thin re-export layer. Where the legacy index.jsx
re-exported the plugin component, it now exports the App as the default so
that site configs and Tutor’s addApp(siteConfig, myApp) can import it with
no knowledge of the package’s internals:
import sampleApp from './app';
import MyWidget from './MyWidget';
export default sampleApp;
export { sampleApp, MyWidget };
The default export is the App. Named exports let consumers reach individual pieces for testing or composition.
Update package.json#
Drop @openedx/frontend-build / fedx-scripts, drop
@edx/frontend-platform, and switch to peer-depending on
@openedx/frontend-base:
{
"name": "@org/my-plugin",
"main": "dist/index.js",
"exports": { ".": "./dist/index.js" },
"files": ["dist"],
"sideEffects": false,
"scripts": {
"build": "tsc --project tsconfig.build.json",
"clean": "rm -rf dist"
},
"peerDependencies": {
"@openedx/frontend-base": "^1.0.0",
"@openedx/paragon": "^23",
"@types/react": "^18",
"react": "^18"
}
}
The build can be any vanilla bundler. The core frontend-base apps use tsc,
which is the simplest option if you’re not transforming SCSS or assets. The
legacy fedx-scripts babel src --out-dir dist no longer works because
consumers won’t have those tools.
Add tsconfig.json and tsconfig.build.json:
// tsconfig.json
{
"extends": "@openedx/frontend-base/tools/tsconfig.json",
"include": ["src/**/*"]
}
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
},
"include": ["src/**/*"]
}
Delete the old package-lock.json and regenerate it: the dep tree changes
substantially.
Port the Tutor plugin#
With the npm package now an App, the Tutor plugin’s job changes from
wiring plugins into per-MFE images to composing your App into the site. In
practice that means swapping the PLUGIN_SLOTS and env.config.jsx hooks
for their FRONTEND_* and mfe-site-* equivalents: declaring the App
so tutor-mfe installs it, and patching the site config so the shell actually
registers it. The subsections below cover which hooks to stop using, the
minimum wiring to get the App onto the site, and how the old patch names
map to the new ones.
Stop using PLUGIN_SLOTS for new code#
PLUGIN_SLOTS still works for legacy MFEs that remain in production, but it
has no effect on the frontend-base site. The frontend-base equivalents are:
tutormfe.hooks.FRONTEND_APPS- declares which Apps are bundled into the site and where their npm packages come from.tutormfe.hooks.FRONTEND_SLOTS- registers ad-hoc slot operations directly on the site’scustomApp(no separate npm package needed). Best for one-off ops.The
mfe-site-config-importsandmfe-site-configpatches - inject imports and code intosite.config.build.tsxandsite.config.dev.tsx. Use these in combination withFRONTEND_APPSto actually wire your App in.
The minimum viable wiring#
Putting those hooks together, here is the smallest Tutor plugin that gets a
ported App rendering on the site. It does three things: enables the
built-in app whose slots you’re targeting, declares your package so tutor-mfe
installs it, and patches the site config to import and register your App.
Each piece is explained below the snippet.
from tutor import hooks
from tutormfe.hooks import FRONTEND_APPS
# Enable the built-in app whose slots you're targeting, if it ships disabled.
@FRONTEND_APPS.add()
def _enable_target(apps):
apps["learner-dashboard"]["enabled"] = True
return apps
# Declare your plugin. tutor-mfe will `npm install` this into the site.
@FRONTEND_APPS.add()
def _add_my_app(apps):
apps["my-plugin"] = {
"npm_package": "@org/my-plugin",
"npm_version": "^1.0.0",
"enabled": True,
}
return apps
hooks.Filters.ENV_PATCHES.add_item((
"mfe-site-config-imports",
"import myApp from '@org/my-plugin';",
))
hooks.Filters.ENV_PATCHES.add_item((
"mfe-site-config",
"addApp(siteConfig, myApp);",
))
All of the above are required:
FRONTEND_APPS enable - some built-in apps (notably
authnandlearner-dashboard) ship disabled in tutor-mfe; others (e.g.,instructor-dashboard,notifications) ship enabled. Check the tutor-mfe README, and flipenabled = Truefor any disabled app whose slots you target. Without it, the legacy per-MFE image is served and your slot ops are inert.FRONTEND_APPS add - tutor-mfe handles the npm install during the site image build. Alternatively, set
"source": "file://..."or a git URL to point at a local or forked checkout.Note
source-based installs are copied into the site as workspace packages undersite/packages/frontend-app-<name>/. During the site image build,npm installinstalls each workspace package’sdevDependencies, andturbo run buildinvokes each package’s ownbuildscript. That means your source package must declare a workingbuildscript (e.g.,tsc --project tsconfig.build.json) and any build-time tools not already provided by@openedx/frontend-base(Babel, etc.) indevDependencies; otherwise no transpilation happens and the site bundler will choke on raw TypeScript or JSX. Packages installed from npm are already built, so this caveat only applies tosource-based installs.mfe-site-config-imports + mfe-site-config - this is what actually puts your plugin app on the site.
Patch-name translation#
If your Tutor plugin reaches beyond the FRONTEND_APPS and FRONTEND_SLOTS
hooks above and patches the config files directly, the patch names change too:
the legacy patches operated on each MFE’s env.config.jsx, while their
frontend-base counterparts operate on the site’s site.config.*.tsx. The
table below maps the ones you’re most likely to have used. Anything not listed
either has no equivalent or is unchanged:
Legacy patch |
frontend-base patch |
What it does |
|---|---|---|
|
|
Install extra npm packages into a workspace. The legacy patch ran in every MFE image; the new patch runs in the site image. |
|
|
Static imports at the top of the config file. |
|
|
Inline definitions reachable from the config. Less commonly needed; prefer an App package. |
(no equivalent) |
|
Arbitrary code that runs after |
|
(unchanged) |
LMS Python settings shared between MFE and frontend-base flows. |
Slot id and operation translation#
The slot ids and operation types are the same ones you translated on the
frontend-plugin side (see Replace the slot-config shape with a frontend-base
App): reverse-DNS slotId values and WidgetOperationTypes ops, with
defaultContent as the default widget’s relatedId.
The difference here is how you express them. FRONTEND_SLOTS is the simple
path for one-off operations that don’t warrant their own npm package: rather
than importing an App, you hand tutor-mfe the slot operation as a string
literal that it drops onto the site’s customApp. Because it’s a plain
string, you name the op with its camelCase string form (for example,
'widgetRemove') instead of importing the WidgetOperationTypes enum:
from tutormfe.hooks import FRONTEND_SLOTS
FRONTEND_SLOTS.add_items([
"""
{
slotId: 'org.openedx.frontend.slot.footer.main.v1',
op: 'widgetRemove',
id: 'hideFooter',
relatedId: 'defaultContent',
}""",
])
The string 'widgetRemove' (lowercase, camelCase) is equivalent to
WidgetOperationTypes.REMOVE and avoids needing an extra import.
Don’t want to port yet? Use the compat shim#
For plugins you can’t or don’t want to rewrite, tutor-mfe ships a
translation layer via @openedx/frontend-base-compat. It runs your legacy
env.config.jsx under a stub App on the frontend-base site.
The shim can be opted into at two granularities, depending on how much of a tutor plugin you want to run through it.
The coarse option takes everything at once: pass a tutor plugin’s name and
every PLUGIN_SLOTS contribution it makes is run through the shim. This is
the least effort and a good starting point when you just want an unported
plugin working on the site, but it’s all-or-nothing, so you can’t port some of
that plugin’s slots natively while shimming the rest.
from tutormfe.hooks import FRONTEND_COMPAT_PLUGINS
FRONTEND_COMPAT_PLUGINS.add_item("my-tutor-plugin")
The fine option takes one slot at a time. You register the same
PLUGIN_SLOTS tuple with both PLUGIN_SLOTS and FRONTEND_COMPAT_SLOTS,
which lets you shim individual slots while porting others in the same plugin.
The catch is that the two registrations must be kept in sync by hand: if the
slot definition drifts in one place and not the other, the legacy MFE and the
site will disagree.
from tutormfe.hooks import PLUGIN_SLOTS, FRONTEND_COMPAT_SLOTS
MY_SLOT = ("learning", "course_outline_sidebar.v1", "{ ... }")
PLUGIN_SLOTS.add_item(MY_SLOT)
FRONTEND_COMPAT_SLOTS.add_item(MY_SLOT)
Caveats the compat README spells out in detail:
The shim covers the runtime import surface of
@edx/frontend-platformand@openedx/frontend-plugin-framework. CSS selectors, the theme-variantdata-attribute, and@openedx/frontend-build(fedx-scripts) are not shimmed. Brand stylesheets that key off the olddata-paragon-theme-variantattribute orselected-paragon-theme-variantlocalStorage key must be updated.PLUGIN_OPERATIONS.ModifyandslotOptions.mergePropsaren’t translated.Wrapis translated best-effort; wrappers that read FPF-private context warn at runtime.
The shim is explicitly a migration aid. Plan to port to native frontend-base App packages over time.
Conversion checklist#
The preceding sections explain the why and the how; this is the condensed punch list to work against once you understand them. It gathers every discrete change described above into three groups - the frontend plugin, the Tutor plugin, and the validation steps that confirm the port - so you can track a port to completion without re-reading the prose. If a line here is unfamiliar, the section it came from has the full detail.
For the frontend plugin (npm package):
Replace every
@openedx/frontend-plugin-frameworkimport with@openedx/frontend-base.Replace every
@edx/frontend-platformimport (including/auth,/i18n) with@openedx/frontend-base.Replace every
getConfig().UPPER_SNAKE_CASEwithgetSiteConfig().camelCase(orgetAppConfig(appId).camelCase).Add an
app.tsx/app.jsxthat exports a frontend-baseAppwith the slot operations.Make the package’s default export the
App(named exports for components).Update slot ids to their reverse-DNS frontend-base equivalents.
Replace
PLUGIN_OPERATIONSvalues withWidgetOperationTypesvalues.Replace
default_contentswithdefaultContenteverywhere.Drop
@openedx/frontend-build/fedx-scripts. Build withtscor an equivalent.Update
package.jsonpeer deps to@openedx/frontend-base.Delete the stale
package-lock.json.
For the Tutor plugin:
Confirm tutor-mfe is recent enough to expose
FRONTEND_APPS,FRONTEND_SLOTS, andmfe-site-*patches.Enable any built-in
FRONTEND_APPSwhose slots you target (they ship disabled).Add your npm package via
FRONTEND_APPS(or asourceURL for local development).Replace
mfe-dockerfile-post-npm-install*withmfe-dockerfile-post-npm-install-site(only if you’re installing extra packages not covered byFRONTEND_APPS).Replace
mfe-env-config-buildtime-importswithmfe-site-config-imports.Add an
mfe-site-configpatch that callsaddApp(siteConfig, yourApp).Drop
PLUGIN_SLOTS.add_item(("mfe-name", ...))calls (or keep them only for legacy MFEs you also support).If the brand override URL points at a CDN-served file, check the branch or tag in the URL still resolves.
If you can’t port immediately, opt in to
FRONTEND_COMPAT_PLUGINSorFRONTEND_COMPAT_SLOTSinstead.
To validate the migration:
npm install+npm run buildsucceeds in the frontend plugin package.tutor config savesucceeds with the new plugin enabled.tutor images build mfe openedxsucceeds (both pip and npm installs run at image-build time).The frontend-base site loads and renders the affected slot correctly at
http://apps.local.openedx.io:8080.The browser devtools show your widget id in the expected slot, with
defaultContentremoved if you usedREPLACE.
See also
Maintenance chart
Review Date |
Working Group Reviewer |
Release |
Test situation |
2026-05-15 |
Adolfo Brandes |
Verawood |
Pass |