loading...

Ember Micro-Frontends with single-spa

rajasegar profile image Rajasegar Chandran ・9 min read

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Generate two routes for people and planets

ember g route people
ember g route planets
Enter fullscreen mode Exit fullscreen mode

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}}

Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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

References:

Discussion

pic
Editor guide
Collapse
flexyford profile image
Alex Ford

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?

Collapse
rajasegar profile image
Rajasegar Chandran Author

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...

Collapse
jelhan profile image
Jeldrik Hanschke

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.

Collapse
rajasegar profile image
Rajasegar Chandran Author

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