To use the latest version of frontend-base from a git repository, you'll need to use npm pack and install it into your MFE from the resulting .tgz file.
Following these steps turns your MFE into a library that can be built using frontend-base and its shell application. It involves deleting a lot of unneeded dependencies and code.
Clone this repository as a peer of your micro-frontend folder(s).
You'll need to install dependencies and then build this repo at least once.
Here or at any point below, consider removing any undesired or previously deprecated features from the codebase, such as organization-specific code. (Just remember to follow the DEPR process for previously undeprecated code.) This will make the refactoring process proportionately easier.
You can start by modernizing the main README, such as:
- Removing references to the devstack, leaving only Tutor instructions
- Change "MFE" to "frontend app"
As you refactor the app, come back to the README and update it accordingly.
- Uninstall
@edx/frontend-platform - Uninstall
@openedx/frontend-build - Uninstall
@edx/frontend-component-headerif it is installed. - Uninstall
@edx/frontend-component-footerif it is installed. - Uninstall
@openedx/frontend-plugin-frameworkif it is installed.
npm uninstall @edx/frontend-platform @openedx/frontend-build
npm uninstall @edx/frontend-component-header @edx/frontend-component-footer @openedx/frontend-plugin-framework
We don't need these anymore, remove them from the package.json dependencies and remove any imports of them in the code:
@edx/reactifexhuskyglob
rm package-lock.json
rm -rf node_modulesDependencies shared with the shell should be moved to peerDependencies. These include:
"dependencies" {
- "@openedx/paragon": "^23.4.5",
- "@tanstack/react-query": "^5.81.2",
- "react": "^17.0.2",
- "react-dom": "^17.0.2",
- "react-router": "^6.26.1",
- "react-router-dom": "^6.26.1",
},
"peerDependencies": {
+ "@openedx/paragon": "^23",
+ "@tanstack/react-query": "^5",
+ "react": "^18",
+ "react-dom": "^18",
+ "react-router": "^6",
+ "react-router-dom": "^6",
}Note that it's possible that when doing this, you encounter peer conflict errors that you must resolve. A good way to do this is to temporarily remove all dependencies, add the peerDependencies and npm i, then add devDependencies and npm i again, followed by your other dependencies. This will ensure that your dependency versions work around the peer dependencies, rather than the other way around.
npm installThis gives us a clean baseline. Historically, changes to dependencies have caused subtle and confusing problems without a clean re-installation like the above.
Run:
npm i --save-peer @openedx/frontend-baseYour package.json should now have a line like this:
"peerDependencies": {
+ "@openedx/frontend-base": "^1.0.0",
},But change it to look like this:
"peerDependencies": {
+ "@openedx/frontend-base": "^1.0.0 || 0.0.0-dev",
},The 0.0.0-dev alternative is the placeholder version used in the source checkout — semantic-release replaces it with the real version at publish time, but in npm workspaces the package still needs to satisfy peer dependency checks. The || lets both scenarios work.
With the exception of any custom scripts, replace the scripts section of your MFE's package.json file with the following:
"scripts": {
"build": "make build",
"build:ci": "make build-ci",
"clean": "make clean",
"dev": "PORT=YOUR_PORT PUBLIC_PATH=/YOUR_APP_NAME openedx dev",
"i18n_extract": "openedx formatjs extract",
"lint": "openedx lint .",
"lint:fix": "openedx lint --fix .",
"prepack": "npm run clean && npm run build",
"snapshot": "openedx test --updateSnapshot",
"test": "openedx test --coverage --passWithNoTests"
},The build script invokes a Makefile target. You'll need to install tsc-alias as a dev dependency:
npm install --save-dev tsc-aliasAlso:
- Replace
YOUR_PORTwith the desired port, of course. - Replace
YOUR_APP_NAMEwith the basename used on your site.config, not doing this will result in only the root route working. - Note that
fedx-scriptsno longer exists, and has been replaced withopenedx.
Tip
Why change fedx-scripts to openedx?
A few reasons. One, the Open edX project shouldn't be using the name of an internal community of practice at edX for its frontend tooling. Two, some dependencies of your MFE invariably still use frontend-build for their own build needs. This means that they already installed fedx-scripts into your node_modules/.bin folder. Only one version can be in there, so we need a new name. Seemed like a great time for a naming refresh. |
Last but not least, add clean: and build: targets to your Makefile. The build target compiles TypeScript to JavaScript, copies all SCSS and asset files from src/ into dist/ preserving directory structure, and finally uses tsc-alias to rewrite @src path aliases to relative paths:
Note that build intentionally does not depend on clean. This allows incremental rebuilds during development (especially in workspace mode, where a watcher triggers build on every change). The prepack script in package.json runs clean && build explicitly, so published packages always start fresh.
clean:
rm -rf dist
build:
tsc --project tsconfig.build.json
find src -type f \( -name '*.scss' -o -path '*/assets/*' \) -exec sh -c '\
for f in "$$@"; do \
d="dist/$${f#src/}"; \
mkdir -p "$$(dirname "$$d")"; \
cp "$$f" "$$d"; \
done' sh {} +
tsc-alias -p tsconfig.build.json
build-ci:
SITE_CONFIG_PATH=site.config.ci.tsx openedx buildNote that the find command copies all files under assets/ directories regardless of type, so you don't need to enumerate asset extensions. Also note that tsc-alias runs after the copy step so that it can resolve @src aliases pointing to asset files. If it ran before, it wouldn't find them and would omit them from the relative path conversion.
The build-ci target is separate from build because apps are distributed build-less: build compiles only the library dist/ via tsc, which doesn't verify that the app can actually be webpack-bundled as a deployable site. build:ci runs openedx build against a dedicated site.config.ci.tsx (which imports the real app) so webpack traverses the actual app graph, catching errors such as broken imports in lazy-loaded routes that neither tsc nor Jest would surface. A dedicated CI config is used rather than site.config.dev.tsx (which may include dev--only concerns) or site.config.test.tsx (which typically registers an inline app stub for Jest's sake and therefore never pulls the real app into the webpack graph). The CI config's URLs and environment value are inert at build time, since nothing executes the produced bundle.
Add a corresponding step to your GitHub Actions workflow after the existing build step, so CI fails on any breakage that would only surface at webpack bundle time:
- name: Build (CI)
run: npm run build:ci- Change the author to "Open edX"
Define the public API for your package:
"exports": {
".": "./dist/index.js"
},The exports map decouples your public API from the internal dist/ directory structure. Consumers import from clean paths and the map resolves them to the actual files in dist/.
Package the compiled output in dist:
"files": [
"/dist"
],This tells webpack that the code from the library can be safely tree-shaken, except for CSS/SCSS files which are imported purely for their side effects.
"sideEffects": [
"*.css",
"*.scss"
],Finally, make sure the following fields are set properly:
"name": "@openedx/frontend-app-[YOUR_APP]",
"version": "1.0.0",
"author": "Open edX",
"license": "AGPL-3.0",
Since we use the files field in package.json to whitelist only /dist, the .npmignore file is largely unnecessary. You can keep a minimal version:
node_modules
This is the current standard .gitignore:
node_modules
npm-debug.log
coverage
dist/
packages/
/.turbo
/turbo.json
/*.tgz
### i18n ###
src/i18n/transifex_input.json
src/i18n/messages.ts
src/i18n/messages/
### Editors ###
.DS_Store
*~
/temp
/.vscode
Create an app.d.ts file in the root of your MFE with the following contents:
/// <reference types="@openedx/frontend-base" />
declare module 'site.config' {
export default SiteConfig;
}
declare module '*.svg' {
const content: string;
export default content;
}Create a tsconfig.json file and add the following contents to it:
{
"extends": "@openedx/frontend-base/tools/tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"paths": {
"@src/*": ["./src/*"]
}
},
"include": [
"src/**/*",
"app.d.ts",
"babel.config.js",
"eslint.config.js",
"jest.config.js",
"site.config.*.tsx",
],
}This assumes you have a src folder and your build goes in dist, which is the best practice.
The paths configuration above sets up the @src alias, which allows you to import from your app's src directory using @src/... instead of relative paths. For example:
// Instead of:
import { MyComponent } from '../../../components/MyComponent';
// You can use:
import { MyComponent } from '@src/components/MyComponent';For this to work, the app must define its own @src path mapping in tsconfig.json. tsc-alias will then rewrite @src imports to relative paths during the build step, so the compiled JavaScript has proper paths.
Create a tsconfig.build.json file for compiling your app before publishing. It will:
- Extend your main
tsconfig.json - Output compiled JavaScript and type declarations to
dist/ - Exclude test files and mocks from the published package
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
},
"include": [
"src/**/*"
],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx",
"src/__mocks__/**/*",
"src/setupTest.js"
]
}Replace the import from 'frontend-build' with 'frontend-base'.
- const { createConfig } = require('@openedx/frontend-build');
+ const { createConfig } = require('@openedx/frontend-base/tools');Use 'test' instead of 'jest' as the config type for createConfig()
module.exports = createConfig('test', {
// ... custom config
})Jest test suites that test React components that import SVG and other assets (such as PNGs) must add mocks for those filetypes. This can be accomplished by adding module name mappers to jest.config.js. Just make sure they come before the @src alias, which must also be added here if you're using it:
moduleNameMapper: {
'\\.svg$': '<rootDir>/src/__mocks__/svg.js',
'\\.png$': '<rootDir>/src/__mocks__/file.js',
'^@src/(.*)$': '<rootDir>/src/$1',
},Then, create a src/__mocks__ folder and add the necessary mocks.
svg.js:
module.exports = 'SvgURL';file.js:
module.exports = 'FileMock';You can change the values of "SvgURL", and "FileMock" if you want to reduce changes necessary to your snapshot tests; the old values from frontend-build assume svg is only being used for icons, so the values referenced an "icon" which felt unnecessarily narrow.
This is necessary because we cannot write a tsconfig.json in frontend apps that includes transpilation of the "tools/jest" folder in frontend-base, it can't meaningfully find those files and transpile them, and we wouldn't want all apps to have to include such idiosyncratic configuration anyway. The SVG mock, however, requires ESModules syntax to export its default and ReactComponent exports at the same time. This means without moving the mocks into the app code, the SVG one breaks transpilation and doesn't understand the export syntax used. By moving them into the app, they can be easily transpiled along with all the other code when jest tries to run.
An uncustomized jest.config.js looks like:
const { createConfig } = require('@openedx/frontend-base/tools');
module.exports = createConfig('test', {
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
// If you want to add config BEFORE jest loads, use setupFiles instead.
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/i18n',
'src/__mocks__',
],
moduleNameMapper: {
'\\.svg$': '<rootDir>/src/__mocks__/svg.js',
'\\.png$': '<rootDir>/src/__mocks__/file.js',
'^@src/(.*)$': '<rootDir>/src/$1',
},
});Jest needs a babel.config.js file to be present in the repository. It should look like:
const { createConfig } = require('@openedx/frontend-base/tools');
module.exports = createConfig('babel');frontend-platform used environment variables to seed the configuration object, meaning it had default values at the time code is loaded based on process.env variables. frontend-base has a hard-coded, minimal configuration object that must be augmented by a valid site config file at initialization time. This means that any tests that rely on configuration (e.g., via getSiteConfig()) must first initialize the configuration object. This can be done for tests by adding these lines to setupTest.js:
import siteConfig from 'site.config';
import { mergeSiteConfig } from '@openedx/frontend-base';
mergeSiteConfig(siteConfig);ESLint has been upgraded to v9, which has a new 'flat' file format. Replace the repository's .eslintrc.js file with a new eslint.config.js file with the following contents:
// @ts-check
const { createLintConfig } = require('@openedx/frontend-base/tools');
module.exports = createLintConfig(
{
files: [
'src/**/*',
'site.config.*',
],
},
);The base eslint config provided by frontend-base ignores a number of common folders by default:
{
ignores: [
'coverage/*',
'dist/*',
'node_modules/*',
'**/__mocks__/*',
'**/__snapshots__/*',
],
},You can configure additional ignores in your own eslint.config.js file using the above syntax, as a separate object from the existing 'files' object:
module.exports = createLintConfig(
{
files: [
'src/**/*',
'site.config.*',
],
},
+ {
+ ignores: [
+ 'ignoredfolder/*'
+ ]
+ }
);Find any other imports/usages of frontend-build in your repository and replace them with frontend-base so they don't break.
Description fields are now required on all i18n messages in the repository. This is because of a change to the ESLint config.
Translations are now pulled and prepared using the openedx translations:pull CLI command. Add an atlasTranslations field to your package.json so the command knows where to find your app's translations and which dependencies to resolve transitively:
"atlasTranslations": {
"path": "translations/frontend-app-[YOUR_APP]/src/i18n/messages",
"dependencies": ["@openedx/frontend-base"]
}Also add a translations:pull script to your package.json:
"scripts": {
"translations:pull": "openedx translations:pull"
}And update your pull_translations Makefile target to use it:
pull_translations: | requirements
npm run translations:pull -- --atlas-options="$(ATLAS_OPTIONS)"Running npm run translations:pull will pull translations from openedx-translations and generate src/i18n/messages.ts.
Add a src/i18n/index.ts file that re-exports the generated messages:
export { default } from './messages';Also add a src/i18n/messages.d.ts type declaration file so TypeScript knows the shape of the generated module even before translations:pull has been run:
import type { SiteMessages } from '@openedx/frontend-base';
declare const messages: SiteMessages;
export default messages;We have removed the @svgr/webpack loader because it was incompatible with more modern tooling (it requires Babel). As a result, the ability to import SVG files into JS as the ReactComponent export no longer works. We know of a total of 5 places where this is happening today in Open edX MFEs - frontend-app-learning and frontend-app-profile use it. Please replace that export with the default URL export and set the URL as the source of an <img> tag, rather than using ReactComponent. You can see an example of normal SVG imports in test-site/src/ExamplePage.tsx.
In frontend-build, createConfig and getBaseConfig could be imported from the root package (@openedx/frontend-build). They have been moved to a sub-directory to make room for runtime exports from the root package (@openedx/frontend-base).
- const { createConfig, getBaseConfig } = require('@openedx/frontend-build');
+ const { createConfig, getBaseConfig } = require('@openedx/frontend-base/tools');You may have handled this in steps 4 and 5 above (jest.config.js and .eslintrc.js)
frontend-base includes all exports from frontend-platform. Rather than export them from sub-directories, it exports them all from the root package folder. As an example:
- import { getConfig } from '@edx/frontend-platform/config';
- import { logInfo } from '@edx/frontend-platform/logging';
- import { FormattedMessage } from '@edx/frontend-platform/i18n';
+ import {
+ getSiteConfig,
+ logInfo,
+ FormattedMessage
+ } from '@openedx/frontend-base';Note that the configure functions for auth, logging, and analytics are now exported with the names:
configureAuthconfigureLoggingconfigureAnalyticsconfigureI18n
Remember to make the following substitution for these functions:
- import { configure as configureLogging } from '@openedx/frontend-platform/logging';
+ import { configureLogging } from '@openedx/frontend-base';Finally:
- Replace all instances of
AppProviderwithSiteProvider
You may find that your test suite explicitly mocks parts of frontend-platform; this is usually done by sub-folder, and sometimes replaces the import with mocked versions of some functions:
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logInfo: jest.fn(),
}));This is problematic now that all imports from frontend-base are from the root package folder, as it means you'll be mocking much more than you intended:
jest.mock('@openedx/frontend-base', () => ({
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),
logInfo: jest.fn(),
}));The above will mock everything but those three functions. This will likely break many tests.
To get around this, consider patching the bulk of the implementations back into your mock via jest.requireActual:
jest.mock('@openedx/frontend-base', () => ({
+ ...jest.requireActual('@openedx/frontend-base'),
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),
logInfo: jest.fn(),
}));In this case, the default implementations of most frontend-base exports are included, and only the three afterward are mocked. In most cases, this should work. If you have a more complicated mocking situation in your test, you may need to refactor the test.
Frontend-base uses site.config.*.tsx files for configuration, rather than .env files. The development file is site.config.dev.tsx, and the test file is site.config.test.tsx; these are the only ones that needs to be included in the app.
Site config is a new schema for configuration. Notably, config variables are camelCased like normal JavaScript variables, rather than SCREAMING_SNAKE_CASE.
The required configuration at the time of this writing is:
- siteId: string
- siteName: string
- baseUrl: string
- lmsBaseUrl: string
- loginUrl: string
- logoutUrl: string
Other configuration is now optional, and many values have been given sensible defaults. But these configuration variables are also available (as of this writing):
- environment: EnvironmentTypes
- basename: string
- runtimeConfigJsonUrl: string | null
- accessTokenCookieName: string
- languagePreferenceCookieName: string
- userInfoCookieName: string
- csrfTokenApiPath: string
- refreshAccessTokenApiPath: string
- ignoredErrorRegex: RegExp | null
- segmentKey: string | null
Note that the .env files and env.config.js files also include a number of URLs for various micro-frontends and services. These URLs should now be expressed as part of the apps config as route roles, and used in code via getUrlForRouteRole(). Or as externalRoutes.
// Creating a route role with for 'example' in an App
const app: App = {
...
routes: [{
path: '/example',
id: 'example.page',
Component: ExamplePage,
handle: {
roles: ['example']
}
}],
};
// Using the role in code to link to the page
const examplePageUrl = getUrlForRouteRole('example');App-specific configuration can be expressed by adding an config section to the app, allowing arbitrary variables:
const app: App = {
...
config: {
myCustomVariableName: 'my custom variable value',
},
};These variables can be used in code with the getAppConfig function:
getAppConfig('myapp').myCustomVariableNameOr via useAppConfig() (with no need to specify the appId), if CurrentAppProvider is wrapping your app.
Refer to the frontend-base branch in frontend-template-application for complete examples of site.config.dev.tsx and site.config.test.tsx.
Observe the following file and directory structure. Not counting any extra files the MFE needs, this is what the src directory should look like for all frontend apps in the Open edX org:
src
(...)
├── sass
├── slots
├── widgets
├── Main.jsx
├── app.ts
├── constants.ts
├── index.ts
├── messages.js
├── providers.ts
├── routes.tsx
├── setupTest.tsx
├── slots.tsx
└── style.scss
A brief explanation of the new ones:
slots: renamed fromplugin-slotswidgets: where any built-in widgets should be createdMain.jsx: the spiritual successor toindex.jsx: where the root component is defined, including an<Outlet />if the main route has childrenconstants.ts: should contain an export of the app'sappIdindex.ts: the MFE is now a library, and this is where all the interesting bits are exported; this file should only contain exports, no react componentsapp.ts: the app configuration that will be imported bysite.configfilesproviders.ts: where global context providers are definedroutes.tsx: where the app's routes are declaredslots.tsx: what slots the app uses; this is distinct from the slots the app offers, which are defined in theslotsdirectorystyle.scss: app-scoped runtime styles, imported from component code (an internal implementation detail, not exported)sass: partials used bystyle.scss, if any
Create, rename, and/or move file contents around to match. Refer to a previously converted MFE (such as Learner Dashboard) for examples.
We're moving away from .env files because they're not expressive enough (only string types!) to configure an Open edX frontend. Instead, the test suite has been configured to expect a site.config.test.tsx file. If you're initializing an application in your tests, frontend-base will pick up this configuration and make it available to getSiteConfig(), etc. If you need to manually access the variables, you can import site.config in your test files:
+ import siteConfig from 'site.config';The Jest configuration has been set up to find site.config in a site.config.test.tsx file.
Once you've verified your test suite still works, you should delete the .env.test file.
A sample site.config.test.tsx file:
import type { SiteConfig } from '@openedx/frontend-base';
const siteConfig: SiteConfig = {
siteId: 'test',
siteName: 'localhost',
baseUrl: 'http://localhost:8080',
lmsBaseUrl: 'http://localhost:18000',
loginUrl: 'http://localhost:18000/login',
logoutUrl: 'http://localhost:18000/logout',
// Use 'test' instead of EnvironmentTypes.TEST to break a circular dependency
// when mocking `@openedx/frontend-base` itself.
environment: 'test' as SiteConfig['environment'],
apps: [{
appId: 'test-app',
routes: [{
path: '/app1',
element: (
<div>Test App 1</div>
),
handle: {
roles: ['test-app-1']
}
}]
}],
accessTokenCookieName: 'edx-jwt-cookie-header-payload',
csrfTokenApiPath: '/csrf/api/v1/token',
languagePreferenceCookieName: 'openedx-language-preference',
refreshAccessTokenApiPath: '/login_refresh',
userInfoCookieName: 'edx-user-info',
ignoredErrorRegex: null,
};
export default siteConfig;In your index.(jsx|tsx) file, you need to remove the subscribe and initialization code. If you have customizations here, they will need to migrate to your site.config file instead and take advantage of the shell's provided customization mechanisms.
If your application uses a custom header or footer, you can use the shell's header and footer plugin slots to provide your custom header/footer components. This is done through the site.config file.
This may require a little interpretation. In spirit, the modules of your app are the 'pages' of an Open edX Frontend site that it provides. This likely corresponds to the top-level react-router routes in your app. In frontend-app-profile, for instance, this is the ProfilePage component, amongst a few others. Some MFEs have put their router and pages directly into the index.jsx file inside the initialization callback - this code will need to be moved to a single component that can be exported.
These modules should be unopinionated about the path prefix where they are mounted.
A frontend-base site renders all of its apps inside a single index.html, so the document title only changes if a page explicitly updates it. If your app doesn't set a title, the browser tab keeps whatever the previously rendered app set.
Each route-level page component must therefore set the document title using <Helmet> from react-helmet. In practice this is the small wrapper component the route lazy-loads (typically Main), not the inner content component or any shared layout, slot, or nested widget. The pattern is:
- Add
react-helmetto the app'sdependenciesif it isn't there already. - Add a localized message per page, with the id pattern
{page}.page.titleand a default of{Page Name} | {siteName}. Don't reuse an existing on-page heading message (e.g., the h1/h2 "page title" used in the body): the document title needs{siteName}interpolation that would be wrong on a heading. - Render a
<Helmet>block in the route entry that sets<title>from that message, passingsiteNamefromgetSiteConfig().siteNameas an i18n parameter.
// src/Main.jsx — the component the route lazy-loads
import { CurrentAppProvider, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Helmet } from 'react-helmet';
import { appId } from './constants';
import messages from './messages';
import Dashboard from './containers/Dashboard';
const Main = () => {
const { formatMessage } = useIntl();
return (
<CurrentAppProvider appId={appId}>
<Helmet>
<title>
{formatMessage(messages['learner.dashboard.page.title'], {
siteName: getSiteConfig().siteName,
})}
</title>
</Helmet>
<Dashboard />
</CurrentAppProvider>
);
};
export default Main;// src/messages.js
import { defineMessages } from '@openedx/frontend-base';
export default defineMessages({
'learner.dashboard.page.title': {
id: 'learner.dashboard.page.title',
defaultMessage: 'Dashboard | {siteName}',
description: 'document title for the learner dashboard',
},
});If the app has nested child routes (for example, a parent route with tabbed sub-routes under it), set the title once at the parent route entry. Per-child titles are optional and follow the same pattern in each child component.
Pages with dynamic titles (for example, a course page that reads {Course Name} | {siteName}) follow the same pattern: render <Helmet> once the data is available and pass the dynamic value through formatMessage. Until the data resolves, the previous page's title persists, which is acceptable for the brief loading window.
See ADR 0015 for the full rationale and rejected alternatives.
Frontend apps deal with two distinct sets of styles, and the distinction matters for the styling of the composing site:
- Runtime styles: App-scoped SCSS that lives inside
src/and is imported from the app's component code. It is an internal implementation detail of the package, never exposed through theexportsmap, and loaded automatically whenever the app's code runs (including as a lazy-loaded route in a larger site). - Dev harness imports: The shell's style manifest (
@openedx/frontend-base/shell/style) loads Paragon's base CSS and the shell's own styles. It is imported only fromsite.config.dev.tsx, so it runs when the app is run standalone for development.
Important
Runtime styles must NOT import @openedx/frontend-base/shell/style (or any other source of Paragon base styles). Paragon's base styles set CSS custom properties at :root. If a lazy-loaded app chunk re-injects those declarations, they clobber any brand overrides that the composing site has already applied, breaking theming globally. See the theming guide for details.
Keep app-scoped SCSS under src/ and import it directly from the component code that needs it. The suggested layout is a single src/style.scss entry point, with any partials under src/sass/:
import './style.scss';Any file layout works, as long as the styles are loaded by the app's own JS/TS modules rather than exposed to consumers.
Important
Any SCSS entry that uses @media (--pgn-size-breakpoint-*) must @use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" at the top. That includes src/style.scss and every component-level index.scss imported directly from JS: each is its own PostCSS pass and does not inherit the declarations from siblings. Missing @uses fail silently (the rule never matches any viewport). See the theming guide for the full rationale.
Import the shell's style manifest from site.config.dev.tsx (NOT from site.config.build.tsx or any file that ships). This loads the global styles your app needs when it runs standalone:
+ import '@openedx/frontend-base/shell/style';
const siteConfig: SiteConfig = {
// config document
}
export default siteConfig;Dev harnesses should NOT import brand packages: running against unbranded Paragon defaults surfaces styling bugs that brand overrides would otherwise hide, and it keeps brand assets out of the app's dependency tree entirely.
Your modules will need environment variables that your system merged into config in index.jsx - we need to document and expect those when the module is loaded. You'll need this list in the next step.
Instead, custom variables must go through site config. This can be done by adding a 'config' object to the App's definition
@import is deprecated in the most recent versions of SASS. It must be converted to @use.
If still importing Paragon SCSS variables, you will find that they, in particular, are likely to result in errors when building the app in webpack. The app should be migrated to use CSS variables from Paragon 23, as per the corresponding howto.
configureI18n no longer takes config or loggingService as options
The getLoggingService export from i18n has also been removed. No one should be using that.
getLanguageList has been removed. Modules that need a list of countries should install @cospired/i18n-iso-languages as a dependency.
getSupportedLanguageList now returns an array of objects containing the name and code of all the languages that have translations bundled with the app, rather than a hard-coded list.
getCountryList has been removed. MFEs that need a list of countries should install i18n-iso-countries or countries-list as a dependency.
The getCountryList function can be reproduced from this file in frontend-platform: https://github.com/openedx/frontend-platform/blob/master/src/i18n/countries.js
frontend-app-account should use the supported language list from frontend-base, rather than the hard-coded list in https://github.com/openedx/frontend-app-account/blob/master/src/account-settings/site-language/constants.js
This would help it match the behavior of the footer's language dropdown.
If the MFE uses react-query version 4 or below, upgrade it to 5 as per this guide. Also remove any instances of <QueryClientProvider />, as the shell already provides a global one.
If the MFE uses Redux, consider porting the app over to react-query, as it will make it much easier to handle header (and footer) customization.
frontend-platform used pubsub-js behind the scenes for event subscriptions/publishing. It used it in a very rudimentary way, and the library was noisy in test suites, complaining about being re-initialized. Because of these reasons, we've removed our dependency on pubsub-js and replaced it with a simple subscription system with a very similar API:
subscribe(topic: string, callback: (topic: string, data?: any) => void)publish(topic: string, data?: any)unsubscribe(topic: string, callback: (topic: string, data?: any) => void)clearAllSubscriptions()
The unsubscribe function as a different API than pubsub-js's unsubscribe function, taking a topic and a callback rather than an unsubscribe token.
Consumers who were using the PubSub global variable should instead import the above functions directly from @openedx/frontend-base.
First, rename src/plugin-slots, if it exists, to src/slots. Modify imports and documentation across the codebase accordingly.
Next, the frontend-base equivalent to <PluginSlot /> is <Slot />, and has a different API. This includes a change in the slot ID, according to the new slot naming ADR in this repository. Rename them accordingly. You can refer to the src/shell/dev in this repository for examples.
Frontend apps support npm workspaces <https://docs.npmjs.com/cli/using-npm/workspaces>_ so that developers can work on the app and its dependencies (such as frontend-base) simultaneously, with changes reflected automatically.
+ "workspaces": [
+ "packages/*"
+ ],This tells npm to look in packages/ for local overrides of published packages. The packages/ directory is gitignored (see the .gitignore step above), since it contains development-only bind-mounted checkouts.
Create a turbo.site.json at the repository root. This configures Turborepo <https://turbo.build/>_ to build workspace packages in dependency order and run persistent tasks (watch and dev server) concurrently:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"cache": false
},
"clean": {
"cache": false
},
"watch:build": {
"dependsOn": ["^build"],
"persistent": true,
"cache": false
},
"//#dev:site": {
"dependsOn": ["^build"],
"persistent": true,
"cache": false
}
}
}The file is named turbo.site.json rather than turbo.json to avoid conflicts with turbo v2's workspace validation. When a site repository includes your app as an npm workspace, turbo scans for turbo.json in each package directory and rejects root task syntax (//#) and configs without "extends". By using a different filename, the config is invisible to turbo during workspace runs, and only activated via the Makefile when running standalone (see below).
Create a nodemon.json at the repository root. This configures the watch:build script to rebuild automatically when source files change:
{
"watch": [
"src"
],
"ext": "js,jsx,ts,tsx,scss"
}Install turbo and nodemon as dev dependencies:
npm install --save-dev turbo nodemonThen add the following scripts to package.json:
"build:packages": "make build-packages",
"clean:packages": "make clean-packages",
"dev:site": "make dev-site",
"dev:packages": "make dev-packages",
"watch:build": "nodemon --exec 'npm run build'",And add the corresponding Makefile targets:
TURBO = TURBO_TELEMETRY_DISABLED=1 turbo --dangerously-disable-package-manager-check
# turbo.site.json is the standalone turbo config for this package. It is
# renamed to avoid conflicts with turbo v2's workspace validation, which
# rejects root task syntax (//#) and requires "extends" in package-level
# turbo.json files, such as when running in a site repository. The targets
# below copy it into place before running turbo and clean up after.
turbo.json: turbo.site.json
cp $< $@
# NPM doesn't bin-link workspace packages during install, so it must be done manually.
bin-link:
[ -f packages/frontend-base/package.json ] && npm rebuild --ignore-scripts @openedx/frontend-base || true
build-packages: turbo.json
$(TURBO) run build; rm -f turbo.json
$(MAKE) bin-link
clean-packages: turbo.json
$(TURBO) run clean; rm -f turbo.json
dev-packages: build-packages turbo.json
$(TURBO) run watch:build dev:site; rm -f turbo.json
dev-site: bin-link
npm run devwatch:buildusesnodemonto watch for source changes (as configured innodemon.json) and re-runsnpm run buildon each change. Turbo runs this in each workspace package that defines it.build:packagesbuilds all workspace packages in dependency order (e.g.,frontend-basebefore the app), then runsmake bin-linkto create missing bin links. This is necessary because npm skips bin-linking for workspace packages during install, so without this step theopenedxCLI won't be available innode_modules/.bin.clean:packagesruns thecleanscript in each workspace package.dev:siteis an alias fornpm run devthat also bin-links the frontend-base bin files; turbo uses it as a root-only task (//#dev:site).dev:packagesdepends onbuild-packagesso the CLI is available before starting the watch, then concurrently watches workspace packages for changes and starts the dev server.
The Makefile targets copy turbo.site.json to turbo.json before invoking turbo, then remove the copy afterward. This ensures turbo finds its expected config when running standalone, without leaving a turbo.json that would conflict in a workspace context. The --dangerously-disable-package-manager-check flag and TURBO_TELEMETRY_DISABLED=1 are also set here, keeping turbo invocation details in one place.
To develop against a local frontend-base:
mkdir -p packages/frontend-base
sudo mount --bind /path/to/frontend-base packages/frontend-base
npm install
npm run dev:packagesBind mounts are used instead of symlinks because Node.js resolves symlinks to real paths, which breaks hoisted dependency resolution. Docker volume mounts work equally well (and are what tutor dev uses).
When done, unmount with:
sudo umount packages/frontend-base