I Want to Start Using single-spa:  The Basics

I Want to Start Using single-spa: The Basics

Heredia, Costa Rica

Published on 2023-04-30

Hello! I haven't written anything for a long time due to other obligations, but let's see if I can attract your interest to this new series.

It all started with me wanting to move (at work) our current custom micro-frontend project to something consolidated: single-spa. However, because I cannot blindly start this migration, I need to run some testing and make a few Proof Of Concept projects.

We will put single-spa to the test on various fronts in order to satisfy my work needs: Does it work with my modified React micro frontends? Can I make React play nicely with Svelte? Does Svelte routing interfere with React routing? All these and other questions will be probed for an answer.

This series will cover my journey from learning the basics of single-spa, all the way to using it to convert existing CRA-created React projects, but especially with my eye on the horizon, where React will be replaced with Svelte.

Disclaimer: I may not be able to reveal certain things in the blog, so expect some replacements here and there in future articles.

So What is This About?

If (this === 'this article') then today we are covering the basics:

  • Core concepts of single-spa

  • The tools that single-spa provides, and why not use them

  • Example code for a root project and a mife, both in Svelte

The progression of the learnings will be available in upcoming articles in the series.

Else if (this === 'this series') then it is all about documenting my journey of migrating from a custom micro-frontend solution to single-spa. Why is this interesting? Well, you'll be the judge of that. What I can tell you is that I will be learning how to use single-spa without pre-made template projects or other utilities (SystemJS, most notably). I will try to explore its details and unravel its power on my own, without pre-made recipes as long as it makes sense to me and my goal.

As an example, I will be teaching you how to make a better mife project that allows standalone execution out of the box, as opposed to what you get with create-single-spa. This tool creates mife projects that, by default, don't show your mife. I dislike this dearly. Say No to "Your Microfrontend is not here"!

Bookmark the series now so you don't miss a thing!

With that said, let's get started.

single-spa 101..201

single-spa is a framework that makes it easy for you to integrate "small applications" (mife, or micro-frontend) into one, larger application.

How does single-spa do what it does? By providing:

  • An interface definition all mifes must adhere to.

  • An API around said interface that provides:

    • Registration of mifes

    • The ability to define when a mife should be live and when it should not

    • Data and code sharing between the root and the mifes

    • Control over when to start mife lifecyles

    • A set of events fired at the window level to inform when things happen

  • Utilities to test or enhance the application, most notably the navigateToUrl() function which can be used to easily make a client-side router

Hopefully I'm not missing anything important from that list!

Let's have a look at this interface definition that makes it all possible.

Mife Lifecycle

The one requirement for a mife to be a single-spa mife is to implement, as a minimum, the following 3 functions:

  • bootstrap

  • mount

  • unmount

Said functions must be made directly available as named exports of your mife's main module. Does this confuse you? Maybe not, but just in case, let's also cover this.

It is expected (and don't worry, as this is the norm nowadays) that your mife application be bundled into (ideally, but not necessarily) a single ES module that fulfills the aforementioned exports. I'll show you some example code of how this looks like with a Svelte component that encapsulates what the micro-frontend wants to expose:

import MainComponent from "$lib/MainComponent.svelte";

export function bootstrap (spaData) {
    // Bootstrap code here.
}

export function mount(spaData) {
    // Mount code.  This is where MainComponent gets rendered into a spa-provided element.
}

export function unmount(spaData) {
    // Unmount code.  Undo what mount() did.
}

The above example would be your bundle's entry file, say named spa.ts. You would specify this file as input to your bundler, most famously, webpack or rollup. When you build your application, the bundler will spit out your bundled module, ready to be consumed by single-spa.

Ok, with the mife requirement out of the way, let's see how single-spa defines the root project and some considerations around it. How the root project registers and dynamically mounts and unmounts mifes will follow.

single-spa uses the term "root-config" or "root config" to refer to the project that contains single-spa and is in charge of loading the micro-frontends. I don't like this, so I use "root project".

Root Project

The root project is your application's entry point. It is the HTML code that gets delivered to your customers when they visit the application's domain name.

The developer team from single-spa recommends that you don't use any framework in your root project, and instead create an empty shell that merely loads mifes (Micro-Frontends). You can read what they have to say here.

I have thought about this and essayed the scenario in my head, keeping my context in mind: I will end up with a project that does little, and requires a pod in K8s (I use K8s at work) to serve a single JS file, a single HTML page and one CSS file that sets the common elements of the website's style, such as font, background, body size and so on. Then this page will run the JS and load the application's shell containing the header and navigation menu, which in turn provides the placeholders where other mifes are loaded. Yes, the root project will contain most of the single-spa code (registration of all mifes), but the shell has to define the positions on the page where the mifes are mounted.

To avoid having to update 2 projects every time a mife is added or removed, the shell application should register the other mifes so the root application doesn't have to be updated if the mife list in the shell side grows or shrinks. single-spa accounts for this: As part of the data that single-spa transmits to mifes, the single-spa's main instance is transmitted so mifes can register other mifes.

So far, I get what they say on the referenced page about separation of concerns, and I think my purist side agrees with them, but my practical side is yelling at me: "Are you seriously considering creating a project that will most likely sit there for years untouched?" Because let's think about it: What if my shell project acted as root project? Yes, it will have a reason to change other than changes in the header/menu: It now has to change because I add or remove a mife, or if something else, single-spa-related, changes. Sounds SOLID-breaking, for sure. However, if I go the single-spa way, I will have an almost-empty project that does one thing: Load the shell. Do I want an entirely separate project for this, practically speaking? I am leaning towards "No, I don't want this."

Let's imagine for a moment the contents of the root project. It would be this:

  • One HTML file

  • One JavaScript file/bundle

  • One CSS file/bundle

The HTML imports the CSS, then runs the JavaScript. The JavaScript merely registers and loads the Shell. That's it. That's 100% of the root application because other mifes are registered and positioned by the shell itself, not the root project.

Now let's ask us this question: What does it take to move the root application's functionality inside the shell mife? Answer: Move the CSS to the shell mife, and have the shell mife create the HTML page that imports said CSS. This is the simplest thing in the world. I am now sure: I won't create a root application that isolates single-spa. I see no practical benefits whatsoever, at least at this stage in my learning process.

Now, all this time I have been reasoning this with my work application in mind, which consists of a shell and several microservices living as hierarchical children of said shell. The shell sits on top, always. Would my ruling change if I were to remove this constraint from my head? Let's see.

I believe that I would consider following single-spa's team's recommendation only if I had more than one top-level mife. Still, I bet any multi-top-level-mife application can be imagined as a single-top-level-mife application, which would then rule out single-spa's recomendation, again, as per the previous reasoning.

My conclusion: Don't follow single-spa's recommendation. Write your shell on your favorite framework (Svelte, Vue, Preact, React,... did I say Svelte?), and add the single-spa functionality on top of it to make it your root project.

Of course, feel free to draw your own conclusions and post them in the Comments section so we all benefit from each other!

Registering and Mounting Mifes

Usually, the root project is in charge of both registering and mounting mifes. However, as explained in the previous section, single-spa allows mife registration from within mifes.

We use the registerApplication() function of single-spa to define, or register, mifes. This has to be done in a JavaScript module that always runs, regardless of the URL being loaded. Remember that even in SPA's, a user may type a URL that has a route in it, and then you must ensure that mife registration always run, regardless of the URL. Where? It depends on your root project's framework. For example, in SvelteKit, we could use src/routes/+layout.svelte because all routes usually inherit from it. If you think you could use src/routes/+page.svelte, then you'd be wrong because a user may start your application by typing the URL with a route other than the homepage, and then registration doesn't happen because the user didn't start his session on the homepage, effectively bypassing src/routes/+page.svelte.

Let's see some code:

import { registerApplication } from "single-spa";
import someGlobalConfigurationObject from "./global-config.js";

registerApplication({
    name: 'mife-A',
    app: () => import('@scope/mifea'),
    activeWhen: '/',
    customProps: {
        globalConfig: someGlobalConfigurationObject
    }
});

First, we import registerApplication(). The second import is meant to be an example of a piece of data that we have available in the root project, and that we want to share with the registered mife. This is not part of single-spa, and it is completely optional.

Then we see the call to registerApplication(). It takes a configuration object with the four properties seen in the example: name, app, activeWhen and customProps.

The name property is an identifier we give to the mife. No other mife can be registered with a previously-used name.

This will come into play in an upcoming article. It will be used to enhance HMR (hot module reload) and probably to be able to use SvelteKit's SSR feature.

The app property is the main course: A function that returns an object with the lifecycle functions we talked about earlier: bootstrap, mount and unmount. Instead of a function that returns this object, it can be the object itself, if you have it ready for some reason. However, the most common thing to see here is a function that makes a dynamic import of the mife's module.

Are you wondering about the module's name (@scope/mifea)? This has to do with import maps, discussed later.

The activeWhen property is the dessert: It lets us control when mife-A will be active and when it won't be. It can be a string, in which case it is treated as a URL location route prefix; it can be a function that receives the value of window.location in its first parameter and is meant to return true or false; or it can be an array with a mixture of the two aforementioned cases. The example shows the simplest: "The mife mife-A will be active always" because the route prefix "/" is well, always there for any URL.

Finally, the customProps property is the cherry on top of the dessert: You may pass data from the registrant of the mife (usually the root project) to the mife itself using this property, which is an object containing anything you want. In the example, we are passing some object that represents configuration data meant to be shared amongst all mifes.

Starting Mife Loading

This is the simplest thing in the world to do. Whenever your application is ready to allow mifes to dynamically load and unload, just call start():

import { start } from "single-spa";

registerMifes();
// Do any other pre-work required.  Then fire away!
start();

You are not required to immediately start single-spa after mife registration. Feel free to call start() whenever is convenient for your application.

We are almost done with the basics. Let's talk about import maps.

Import Maps

There is this thing I never knew about until recently: Import maps are "dictionaries" that define, or map, "bare identifiers" to ES module URI's. It is a browser feature that, at the time of this writing, all major browsers support fully.

One defines an import map using a <script> tag with type importmap. It is placed inside the HTML's <head> element, before any JavaScript is loaded. Let's see the import map that would satisfy the previous example where we wrote import('@scope/mifea'):

<!DOCTYPE html>
<html>
    <head>
        <title>Fun with single-spa</title>
        <script type="importmap">
            {
                "imports": {
                    "@scope/mifea": "http://localhost:3100/src/spa.ts"
                }
            }
        </script>
        <script type="module" src="/assets/index.js"></script>
    </head>
    <body>
        <div style="display: contents;">
            ...
        </div>
    </body>
</html>

This map tells the web browser: "If you see imports in JavaScript that say from "@scope/mifea", know that @scope/mifea is really http://localhost:3100/src/spa.ts". This also applies to dynamic imports.

This level of indirection is so handy, that it can even be used to replace individual mifes in the browser, while the application is running! This is the recommended technique for development using single-spa: Open your application from a deployed environment, then override the import map to substitute whichever mife you are developing/testing and have fun. This way we don't have to run several projects locally on our machines just to develop or test changes.


Ok, that was a lot for just "the basics". Still, I believe it is useful. This is the basics summary:

  1. Create your root project using your favorite UI framework or library; there is no practical gain in creating a pure root.

  2. Create mife projects as needed, again, using your favorite UI framework or library.

  3. Register all mifes using registerApplication() in the root project, in a JavaScript module that is guaranteed to run regardless of the URL.

  4. Start the fun calling start() in the root project whenever ready.

  5. Optionally share code or data in the root project with the mifes at the time of registration.

Getting Started with create-single-spa

As seen in other JavaScript frameworks/libraries, single-spa also provides a tool capable of bootstrapping your root or mife projects fast and pain-free. Or is it, really, pain-free? We'll find out soon enough. Let's learn about this create-single-spa tool.

Creating the Root Project

This create-single-spa NPM package is installed globally and then run in the command prompt. To create a root project, run this:

create-single-spa my-root --moduleType root-config

Just like that, the tool will create the my-root subdirectory, and in it, a plain JavaScript project ready for you to add import maps and calls to registerApplication().

However, if you are like me and concluded that a root project that does nothing but loading SPA's is a waste of effort, then this is not for you. Unlearn this!

Creating Mifes

The same create-single-spa tool can be used to create mifes. For example, you could start a new project for a Vue mife like this:

create-single-spa my-vue-mife --framework vue

Note that it is not necessary to specify --moduleType app-

because it is inferred by the tool based on the recommendation (that I don't share so far) of only using frameworks in mifes.

I tested this but unfortunately, at the time of this writing, there is a problem with the generated package.json file because running npm i fails with the following:

NPM Install Error for Svelte parcel

Maybe I should have tried another framework (this failure was with Svelte). Still, we can have a look at what it creates. Let's start with the list of files:

Project files for a parcel project

How do these relate to what we have discussed so far? We can see about that. Keep going.

First, we have App.svelte. This is the main component and the one component that would first appear when single-spa mounts this mife. When does this happen? Well, whenever the activity function(s) associated with this mife returns a true value.

Then we have testorg-test-mife.js. This name comes from my input when running create-single-spa: I was asked for an organization name and a project name, to which I replied testorg and test-mife. This file contains the export of the mife's lifecycle functions bootstrap, mount and unmount. This is the file content:

import singleSpaSvelte from "single-spa-svelte";
import App from "./App.svelte";

const svelteLifecycles = singleSpaSvelte({
  component: App,
});

export const { bootstrap, mount, unmount } = svelteLifecycles;

The single-spa-svelte module comes from an NPM package of the same name. It is a helper package that creates the lifecycle functions with very little input. It is pretty handy, and so far, I approve of its use, if you still care about my personal opinion.

Finally, let's talk about rollup.config.js. This project uses rollup for code bundling. This file is extensive and I am no rollup expert (yet), so let's focus our attention on this piece:

  input: "src/testorg-test-mife.js",
  output: {
    sourcemap: true,
    format: "system",
    name: null, // ensure anonymous System.register
    file: "dist/testorg-test-mife.js",
  },

Just like I stated earlier in the article, we are asking rollup to use the JavaScript file that exports the lifecycle functions as input. The output part of the configuration tells us that the output format will be a SystemJS module, and the destination file will be testorg-test-mife.js in the dist folder.

SystemJS is an alternative to native import map support. It is the preferred import map solution recommended by single-spa, and it is therefore one reason why I personally don't recommend creating mifes with create-single-spa. Still, you can make your own mind yourself. If you are OK with SystemJS, by all means, continue.

Besides defaulting to SystemJS import maps, I see the following as issues:

  1. The project created is not in TypeScript

  2. I don't have Vite

  3. The project is not SvelteKit

The last one is particularly important for me because I like very much the routing system of SvelteKit. Who am I kidding? Vite is also important to me. I love Vite.

At this point, I can either struggle with this tool to make it make projects the way I like them, or I can ditch it altogether and manually set the mifes up. I'm going for the latter option and ditching create-single-spa.

There's also one other point of interest for me: Can create-single-spa update my existing React mifes flawlessly? I bet not because they already are custom projects to be rendered with our proprietary SPA mechanism.


At this point, we are left with no helpers to build root or mife projects, but fear not, as single-spa itself is not very difficult. Let's learn how to make these ourselves.

Getting Started With ... Nothing

The section's title is a bit harsh, but in reality, is a breeze. Let's jump right in using my favorite framework, Svelte.

Creating the Root Project

I want to re-write my shell (currently in React) in Svelte, so I want my root project to be a SvelteKit project. So let's do that:

npm create svelte@latest

This starts the text-based wizard. I just ran it to show you a screenshot of the output:

SvelteKit's output

Now we need to run npm i and then add single-spa:

npm i && npm i single-spa

We are ready to register mifes, if we had any. No issues. We can leave the project ready for mifes in the meantime. Let's add the src/routes/+layout.svelte file with the following code:

<script lang="ts">
    import { registerApplication, start } from "single-spa";

    // Register mifes.
    registerApplication({
        name: "",
        app: () => import(""),
        activeWhen: () => true,
        customProps: {
            // No idea.
        }
    });

    // Start single-spa!
    start();
</script>

And just like that, we have a single-spa root project with all the benefits of SvelteKit, including its nice router, which is what interests me the most for my work application. Sure, all is empty now, but we will come back and fill in the name and module alias as soon as we decide on them.

In reality, a few more things are needed to call this root project "mife ready". Read the example walkthrough in the coming sections.

Creating Mifes as New Projects

The easiest for sure is to create a mife from scratch. For my work application, everything is in React, and for now, I cannot afford to leave React behind in the mifes. I will only be re-writing the shell for now.

Still, this will most likely be the process to create a Svelte + Vite mife project, once I can afford the migration:

npm create vite@latest

This is what I fed the wizard with, and the corresponding output:

Vite + Svelte project creation output

To make this a single-spa mife, we have to do some more work when compared to what we did for the root project. Still, it is nothing out of the ordinary.

First, open src/main.ts. This is the project's input file (the file that gets bundled by rollup). We could modify it to make this project a pure single-spa mife, but instead let's leave it untouched because, in a later article, I will show you how to make this project behave dually: As a regular standalone web application and as a single-spa mife.

Add the helper package named single-spa-svelte:

npm i single-spa-svelte

Then create a new input file: src/spa.ts with the following content:

import App from "./App.svelte";
import singleSpaSvelte from "single-spa-svelte";

const lc = singleSpaSvelte({
    component: App
});

export const { bootstrap, mount, unmount } = lc;

Now we need to tell rollup that this is our new input file. Open the vite.config.ts file and modify it so it looks like this:

import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [svelte()],
    build: {
        target: 'es2022',
        rollupOptions: {
            input: {
                spa: 'src/spa.ts'
            },
            preserveEntrySignatures: 'exports-only',
            output: {
                exports: 'auto',
            }
        }
    },
})

We are telling Vite to target a very modern JavaScript version to allow for top-level awaits, we are changing the input to our new spa.ts file, and we are also asking rollup to please leave our exports exported.

At this point, we should have a live mife! Build the mife project with npm run build. You should get something very similar to this:

Parcel build output

Run the server so we can test it out as explained in the next section. Just execute npm run preview. Make a note of the hostname as it is needed for the next section. It probably is http://localhost:4173.

Putting Root and Mife Together

Now we have a working mife, so let's head to the root project and register it properly. Make the contents of src/routes/+layout.svelte look like this:

<script lang="ts">
    import { registerApplication, start } from "single-spa";

    // Register mifes.
    registerApplication({
        name: "mife01",
        app: () => {
            const module = "@test/mife01";
            return import(/* @vite-ignore */ module);
        },
        activeWhen: '/m01'
    });

    // Start single-spa!
    start();
</script>

<slot />

We now need an import map in the HTML page that maps @test/mife01 to the bundled version of src/spa.ts in our mife project. Open src/app.html and add the import map. The result should look very similar to this:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <link rel="icon" href="%sveltekit.assets%/favicon.png" />
        <meta name="viewport" content="width=device-width" />
        <script type="importmap">
            {
                "imports": {
                    "@test/mife01": "http://localhost:4173/assets/spa-11fb199e.js"
                }
            }
        </script>
        %sveltekit.head%
    </head>
    <body data-sveltekit-preload-data="hover">
        <div style="display: contents">%sveltekit.body%</div>
    </body>
</html>

The right-hand value http://localhost:4173/assets/spa-11fb199e.js is the result of concatenating the host of the mife project server and the path of the bundled spa JavaScript file obtained when we built the mife project.

Now, because we want the mife01 mife to be active if the URL has the path segment /m01, let's add that route to the root project. Create the folder src/routes/p01, and in it, the +page.svelte file. The contents of this file could be something like this:

<h1>Mife 01 Below</h1>

<p>
    This is content placed by the root project. Below this text, 
    you should see the contents of the mife01 mife.
</p>

Now let's place an anchor element in the root project's homepage, just to facilitate testing. Open src/routes/+page.svelte and add the anchor element shown below, in the last line:

<h1>Welcome to SvelteKit</h1>
<p>
    Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation
</p>
<a href="/p01">Let's see Mife 01!</a>

We are theoretically ready. There is one more detail, though, that seems to be specific to Svelte's SSR (Server-Side Rendering). Long story short, SSR and single-spa are not friends right away. For the time being, just disable SSR. Create the file src/routes/+layout.ts with this content:

export const ssr = false;

Ok! Run the root project with npm run dev and open a web browser to look at it. Click the Let's see Mife 01! link. You should see Mife01 now!

Hmm, but it doesn't look right now, does it? The Vite and Svelte logos are missing, and all styling has been lost. Fixing these is the subject of subsequent articles, and at this point, we should be happy about our progress. Pat yourself in the back!

Conclusions

We have learned that single-spa is a powerful resource that is simple to use, albeit not trivial. The create-single-spa utility appears to be hopeless: It is opinionated (forces JavaScript even if you want TypeScript, and there's no Vite) and probably can only update existing projects that conform to out-of-the-box configurations. I, however, did not test the update capabilities, making this a suspicion, not a fact.

At this point, SystemJS seems completely unnecessary and an extra dependency that cannot be justified. When dealing with existing applications, the fewer extra dependencies, the better.

There is no practical gain in creating a bare-bones root project, and instead, it seems to be better just to make the shell project a root project by adding single-spa to a module in the shell that is guaranteed to always run.

single-spa is neither SSR-friendly nor HMR-friendly out of the box.

Manually creating mifes has the effect of losing CSS styles; extra work is needed to be able to serve assets like images from mifes.


Upcoming Articles

In no particular order, we will explore how to fix CSS and how to regain access to assets like images. We will also learn how to create a mife project that simultaneously works as a single-spa mife and as a standalone web application.

We will also be testing the limits of single-spa: Can it really share code and data between mifes? Does mife routing really work out of the box? For example, can a React mife with React routing work flawlessly inside a SvelteKit shell?

Don't forget to follow me if you would like to see what happens next. Once more, here's the link to the series.

Happy coding!