In this article, I am going to walk you through on how to create Micro-Frontends in Ember.js using the single-spa library.
We are going to create a simple application with just only two pages for the list of planets and people references in Star Wars movie. The planets page will list the planets from the films and their details. Similarly the people page will list the characters from the movies and their details.
You can view the ember-micro-frontends in action here and the source code is hosted in this github repo.
Before diving into the tutorial, let's make ourselves clear about the tools and technologies we are going to use.
Micro-Frontends
Micro-Frontends enable us to combine many small apps, empowering teams to choose their technology. It is kinda like the micro-services for backend.
Ember.js
Ember.js is an opinionated, productive, battle-tested JavaScript framework for building modern web applications. It includes everything you need to build rich single page applications with an awesome community and great documentation.
single-spa
single-spa is a JavaScript router for front-end microservices.
With single-spa you can use multiple frameworks in a single-page application, allowing you to split code by functionality and have Angular, React, Vue.js, etc. apps all living in harmony. single-spa makes them work together and won't load them until they're needed.
single-spa-ember
single-spa-ember is a helper library that helps implement single-spa registered application lifecycle functions (bootstrap, mount and unmount) for for use with Ember.js. For more information, check out the github repository.
create-single-spa
single-spa offers a CLI for those who prefer autogenerated and managed configurations for webpack, babel, jest, etc. You can also create single-spa apps without the CLI.
Recommended Setup by single-spa core team
The single-spa core team recommends a default setup to create micro-frontends with any framework based on a set of conventions which will help us to scale our micro frontends in the long run.
We are going to follow the below setup:
ember-micro-frontends
|- root-config
|- navbar
|- people
|- planets
|- styleguide
root-config
This is the host app that manages all the other micro frontends using single-spa.
Let's see how to create the root-config app using the CLI:
Invoke the create-single-spa
command at the terminal
create-single-spa
It will present you with a set of options like directory, type of application. With create-single-spa you can create different type of applications like single-spa-application, in-browser utility and root-config. You can read more about the different type of modules here.
For this project you have to choose the single-spa root config
option and follow the other successive prompts.
? Directory for new project
? Select type to generate (Use arrow keys)
single-spa application / parcel
in-browser utility module (styleguide, api cache, etc)
β― single-spa root config
Once the generators are done, you will have a src
folder with two files. index.ejs
is the html template for our host app where we will have the placeholders for the other micro frontends. Since Ember apps need a DOM element to mount which is known as the root element we will layout the apps inside the body
tag in this file.
<template id="single-spa-layout">
<single-spa-router>
<nav class="topnav">
<application name="navbar" loader="topNav" error="topNav"></application>
<div id="navbar"></div>
</nav>
<div class="main-content mt-16">
<route path="people">
<application name="people"></application>
<div id="people"></div>
</route>
<route path="planets">
<application name="planets"></application>
<div id="planets"></div>
</route>
<route default>
<h1 class="flex flex-row justify-center p-16">
<p class="max-w-md">
This example project shows independently built and deployed
microfrontends that use <a href="https://emberjs.com" target="_blank">Ember.js</a> and <a href="https://single-spa.js.org" target="_blank">single-spa</a>. Each nav link
above takes you to a different microfrontend.
</p>
</h1>
</route>
</div>
</single-spa-router>
</template
And the next file will be root-config.js
. This is where we will register all our Ember micro frontends using the single-spa registerApplication
api like below:
import { registerApplication, start } from "single-spa";
const planetsApp = registerApplication(
"planets",
() => {
const appName = "planets";
const appUrl = `http://localhost:4200/planets/assets/planets.js`;
const vendorUrl = `http://localhost:4200/planets/assets/vendor.js`;
return loadEmberApp(appName, appUrl, vendorUrl);
},
(location) => location.pathname.startsWith("/planets")
);
What the above code does is, it registers an Ember micro-frontend named planets
with single-spa and tells the url for your app.js
and vendor.js
files. Since Ember uses two main Javascript assets - app and vendor we need to tell single-spa from where to load these files. Here we are running a local server for the Ember app planets
.
navbar
This app contains a navigation bar to route to the different micro-frontends based on the url.
Create the project with ember-cli new command.
ember new navbar
Generate two routes for people
and planets
ember g route people
ember g route planets
Create the navigation markup in app/templates/application.hbs
<div id="nav-wrapper" class="h-16 flex items-center justify-between px-6 bg-burnt-ember text-white">
<div class="flex items-center justify-between">
<span class="px-6 font-bold">Ember Micro-Frontends</span>
<LinkTo @route="index" class="p-6">Home</LinkTo>
<LinkTo @route="people" class="p-6">People</LinkTo>
<LinkTo @route="planets" class="p-6">Planets</LinkTo>
</div>
<div class="flex items-center justify-between">
<a
href="https://github.com/ember-micro-frontends"
class="externalLink"
>
Github project
</a>
</div>
</div>
{{outlet}}
You also have to make some changes to your Ember build by turning off autoRun
because Ember apps will automatically try to boot as soon as the scripts are parsed and executed. So single-spa recommends to turn off this setting in order to mount/unmount the apps dynamically during run time.
module.exports = function(defaults) {
let app = new EmberApp(defaults, {
autoRun: false,
storeConfigInMeta: false,
fingerprint: {
customHash: null
}
});
app.import('node_modules/single-spa-ember/amd/single-spa-ember.js', {
using: [
{ transformation: 'amd', as: 'single-spa-ember' }
]
});
return app.toTree();
};
And finally we need to hook up the lifecycles for the Ember app with the single-spa library in app/app.js
at the end of the file.
import singleSpaEmber from 'single-spa-ember';
const emberLifecycles = singleSpaEmber({
App,
appName: 'navbar',
createOpts: {
rootElement: '#navbar',
}
});
export const bootstrap = emberLifecycles.bootstrap;
export const mount = emberLifecycles.mount;
export const unmount = emberLifecycles.unmount;
We have to do the above step for each Ember app we create.
people
This micro frontend is for the people page and list the people and their details using the Starwars API SWAPI.
We are going to fetch the people info from the api in the index route like below:
export default class IndexRoute extends Route {
queryParams = {
id: {
refreshModel: true,
},
};
async model(params) {
const response = await fetch("https://swapi.dev/api/people/");
const _people = await response.json();
_people.results.forEach((p,index) => p.id = ++index);
const people = _people.results;
const person = people.find(p => p.id == params.id);
return { people, person };
}
}
From the api, we will list out the people in a separate component and the details in other component.
ember g component people-list
ember g component selected-people
The index route template will be something like:
<div>
<div class="flex">
<div class="p-6 w-1/3">
{{#if this.nextPage}}
<Button
disabled={{this.disabled}}
loading={{this.loading}}
{{on "click" this.fetchPlanets }}
>
Fetch More Planets
</Button>
{{/if}}
<PeopleList @people={{@model.people}}/>
</div>
<div class="w-2/3 p-6 border-l-2 border-white">
<div class="selectedPlanet">
<SelectedPeople @person={{@model.person}} />
</div>
</div>
</div>
</div>
The markup for the components will be like, we generate a list of links for the index page to show the selected people information on the right.
<div class="people-list">
{{#each @people as |p|}}
<LinkTo @route="index" @query={{hash id=p.id}} class="h-12 flex items-center border-white border-b cursor-pointer no-underline">
{{p.name}}
</LinkTo>
{{/each}}
</div>
And the component to show the people details will have the following markup
<table>
<tr>
<td class="font-bold pr-6 w-40">
Name
</td>
<td>{{@person.name}}</td>
</tr>
<tr>
<td class="font-bold pr-6 w-40">Height</td>
<td>{{@person.height}}</td>
</tr>
<tr>
<td class="font-bold pr-6 w-40">Mass</td>
<td>{{@person.mass}}</td>
</tr>
<tr>
<td class="font-bold pr-6 w-40">Hair color</td>
<td>{{@person.hair_color}}</td>
</tr>
<tr>
<td class="font-bold pr-6 w-40">Gender</td>
<td>{{@person.gender}}</td>
</tr>
<tr>
<td class="font-bold pr-6 w-40">Birth year</td>
<td>{{@person.birth_year}}</td>
</tr>
<tr>
<td class="font-bold pr-6 w-40">Homeworld</td>
<td><a href="/planets#/?id=1">{{@person.homeworld}}</a></td>
</tr>
<tr>
<td>Films
</td>
</tr>
</table>
planets
The planets micro frontend is almost similar to the people micro frontend except it uses the planets api from SWAPI.
export default class IndexRoute extends Route {
queryParams = {
id: {
refreshModel: true,
},
};
async model(params) {
const response = await fetch("https://swapi.dev/api/planets/");
const _planets = await response.json();
_planets.results.forEach((p,index) => p.id = ++index);
const planets = _planets.results;
const planet = planets.find(p => p.id == params.id);
return { planets, planet };
}
}
styleguide
This micro frontend is a special one. Here we will export the Tailwind CSS library utilities to be shared among all the micro frontends for styling with the CSS utility classes from Tailwind.
:root {
--color-primary: #011627;
--color-secondary: #8b8c8a;
--color-danger: #e71d36;
--color-warning: #ff9f1c;
--color-info: #2ec4b6;
--color-background: #182b3a;
--color-black: #171817;
--color-overlay: rgba(253, 255, 252, 0.8);
--color-ember: #e04e39;
--color-burnt-ember: #9b2918;
--color-white: #fdfdfd;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: var(--color-ember);
color: var(--color-white);
}
nav.topnav {
position: fixed;
width: 100vw;
top: 0px;
z-index: 100;
}
Running the app
Once you have done all the above things properly, you can start the Ember microfrontends and see them in action in the local environment by firing up yarn start
from the root-config
app.
yarn start
This will start the webpack-dev-server
in the port 9000 by booting up the app and you can view the app in localhost:9000
Known issues
This approach of doing Ember micro-frontends in single-spa has it's own set of challenges.
History router
As you see in the planets
micro-frontend we are using query params and hash-based routing in Ember because if we use the history routing in Ember, Ember will take control of the browser history and urls and this might create some problems with routing for the other micro-frontends. Hence I recommend to use hash-routing for your Ember micro-frontends if you are using with single-spa. Please let me know how we can solve the issue in the comments if you have any ideas.
Ember Data
Another problem I have faced with Ember micro-frontends is using the ember-data
library and the store that comes with ember-data. Because both the Ember apps have their own ember-data bindings and global objects clashing with each other I got an error something like below during navigating to other pages.
TypeError: Cannot redefine property Inflector
window.require.entries
One more issue I have faced while navigation to previously visited pages is that Ember will reset the module list in window.require.entries
. Say for example, first you visit the people page, your window.require.entries
will be populated with the modules from the people Ember app, next if you visit the product app, Ember will reset the entries of the people app and now the window.require.entries
will only contain the modules from the planets app. So if you try to visit the people page again , you will get a blank page or an error in the console.
Different Ember versions
I haven't tried building the micro-frontends with different Ember.js versions, I am sure that might bring its own set of problems with it. All the micro-frontends built here are using the same Ember version (3.19).
Parting Thoughts
Even though the Ember community recommends Ember Engines for running different Ember apps within a single page, this approach of creating micro-frontends have its own set of merits.
You are not tied with a particular framework implementation, you are free to choose any framework to add to the micro frontends here with other Ember apps.
You don't have to redeploy other Ember apps if you change your dependencies with respect to the other micro frontends, because single-spa registers your dependencies from where you have hosted the other micro-frontends.
You can dynamically mount/unmount you apps with the single-spa-inspector browser addon provided by the single-spa team in your browser. It supports Chrome and Firefox browsers.
You can also override the apps at runtime inside the browser with the addon for testing any micro-frontend in your local development environments.
Please let me your thoughts in the comments about this approach of building Ember micro-frontends using single-spa and also try out single-spa for building micro-frontends in other frameworks like React, Vue, Svelte, Preact, etc.,
You can find the list of examples in this page
Top comments (4)
Did you see any potential workarounds for the Ember Data issue? Iβm not sure how the internals work, but maybe serializing and dumping the contents of the data store into local storage while switching between apps?
Sounds like a good idea to me, there were also some suggestions of using iframes and wrap vendor/app with function which has local βwindowβ variable defined. Basically hiding global window object and proxying minimal set of things through but isolating require.entries list. I haven't tried these yet...
How is the bundle size? I guess bundle size is one of the main trade-offs of this approach compared to Ember Enignes or monolith frontend.
These are the observations for bundle sizes:
Navbar => vendor = 149 KB, app = 3 KB
People => vendor = 149 KB, app = 4.6 KB
Planets => vendor = 149 KB, app = 4.9 KB
I haven't compared this with Engines though, The only drawback I see is having the duplicate code in vendor bundles, but this is definitely better than the Monolith because all the code is lazy loaded only during the mounting process of a micro-frontend