Joe Davidson

Micro Frontends with Golang and Hotwire 3: Building a Micro Frontend Container App

gomicro frontends

Introduction #

This post will be a continuation of my series on MFEs (Micro Frontends), if you haven't read the last 2 then I would recommend doing so before this one.

I'll discuss setting up a container app, in this context container does not relate to docker containers, but in fact the MFE which contains the upstream MFEs. This application will likely be a thin window into the other MFEs. It could contain navigational components for switching between apps, or potentially the navigation can be its own MFE that the container provides a window to.

In my example app [0] I chose to embed the nav in the container app just for simplicity’s sake.

Prerequisites #

You will also need the resulting project from my last post, if you don't have that anymore, then I have pushed the codebase to GitHub [1].

Creating the container app #

For brevity, I'm going to refer back to my last post when directing you to create a new MFE within the project. Use the “Hello” example, and change all references of “hello” to be “container”.

The result should give you a project that looks roughly like the following:

docker
├── docker-compose.yaml
├── Dockerfile
├── moxy-config.toml
└── variables.env
src
├── hello
├── goodbye
└── container

Route via the proxy #

The container app in our case will be the default route for all traffic. So, first we need to handle all traffic in our app by setting the path prefix to empty:

// src/container/app/app.go

package app

import (
"container/types"
"log"
"net/http"

"github.com/BurntSushi/toml"
"github.com/gorilla/mux"
)

func Run(env string) {
var c types.Config

config := "local.toml"
if env != "" {
config = env + ".toml"
}

if _, err := toml.DecodeFile(config, &c); err != nil {
log.Fatal(err)
}

r := mux.NewRouter()
r.PathPrefix("").HandlerFunc(HandleRequest)

err := http.ListenAndServe(c.Port, r)
log.Fatal(err)

}

Then we need to update our docker-compose.yaml to run the container app:

# docker/docker-compose.yaml

version: "3"
services:
hello:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/hello/:/app
goodbye:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/goodbye/:/app
container:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/container/:/app
proxy:
image: joedavidson1802/moxy:v0.2.1
volumes:
- ./moxy-config.toml:/app/defaults.toml
ports:
- "80:80"

And then to expose the container we need to update our Moxy config to fallback on our container app, default_upstream serves this purpose:

# docker/moxy-config.toml

moxy_path = "/moxy"
default_upstream = "http://container"

[[upstream]]
path_prefix = "/apps/hello"
host = "http://hello"

[[upstream]]
path_prefix = "/apps/goodbye"
host = "http://goodbye"

With docker-compose up you should be able to run and see the container page at http://localhost, while still seeing our apps at http://localhost/apps/hello and http://localhost/apps/goodbye.

Implement Turbo #

For orchestrating the MFEs I use Turbo [2], it's designed to make SSR apps faster by not requiring a full page reload. To import turbo we need to edit the container template file.

Update the layout #

<!-- This snippet will not compile as is, html comments are not currently supported in templ -->
<!-- src/container/templates/container.templ -->

{% package templates %}

{% templ ContainerTemplate(url string) %}
<html>
<head>
<script type="module">{%! Raw("import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';") %}</script>
</head>
<body>
<nav>
<a href="/hello" target="_top">{%= "Hello" %}</a>
<a href="/goodbye" target="_top">{%= "Goodbye" %}</a>
</nav>
<turbo-frame id="app-container" src={%= url %}>{%= "Select an app" %}</turbo-frame>
</body>
</html>
{% endtempl %}

First, is the module import, it just loads the turbo library (I'm using a Raw function to avoid my JS being escaped by templ):

<script type="module">{%! Raw("import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';") %}</script>

The Raw function:

// src/container/templates/raw.go

package templates

import (
"context"
"io"

"github.com/a-h/templ"
)

func Raw(text string) (t templ.Component) {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
_, err = io.WriteString(w, text)
return err
})
}

Next, is the nav element, with a link to each of our upstream MFEs. Notice that I'm excluding the /apps prefix that the upstream MFEs require. This is so that the container app intercepts the page-loads rather than the upstream apps being returned on their own.

    <nav>
<a href="/hello" target="_top">{%= "Hello" %}</a>
<a href="/goodbye" target="_top">{%= "Goodbye" %}</a>
</nav>

At the core is the templ-frame this is an element that allows us to render in content from other URLs. Frames require an id, and I'm using the src attribute which tells Turbo where to load the contents from, in this case it's parameterized. And I provide some default text in case there is no URL provided or the frame hasn't loaded yet.

<turbo-frame id="app-container" src={%= url %}>{%= "Select an app" %}</turbo-frame>

One option would have been to call the apps by using a data-turbo-frame on the link, this would cause Turbo to intercept the call, avoiding having to go to the container app every time we make a request. But currently this doesn't support updating browser URLs or history (a feature in the works), this would look like the following. Notice we do provide the /apps prefix here as turbo would call our MFEs and replace the contents of app-container with the new data.

    <nav>
<a href="/apps/hello" data-turbo-frame="app-container">{%= "Hello" %}</a>
<a href="/apps/goodbye" data-turbo-frame="app-container">{%= "Goodbye" %}</a>
</nav>
<turbo-frame id="app-container" src={%= url %}>{%= "Select an app" %}</turbo-frame>

Provide the navigation URL #

In the handler function we now need to pass the request URL down to the template, adding the /apps prefix so that the turbo-frame has an src attribute that is routed to the MFE. We also need to provide an empty string if a request is to the root, to avoid recursive calls.

// src/container/app/container.go

package app

import (
"container/templates"
"net/http"
)

func HandleRequest(w http.ResponseWriter, r *http.Request) {
uri := r.RequestURI

if uri == "/" || uri == "" {
uri = ""
} else {
uri = "/apps" + uri
}

t := templates.ContainerTemplate(uri)

err := t.Render(r.Context(), w)

if err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}

The request journey for our app if someone clicked the “Hello” nav item:

  1. Initial request: http://localhost/hello → Moxy → http://container/hello
  2. Response: <turbo-frame id="app-container" src="/apps/hello">"Select an app"</turbo-frame>
  3. Turbo requests the frame: http://localhost/apps/hello → Moxy → http://hello/apps/hello
  4. Turbo renders the response

Try it out #

We're now half-way there, the container is built. If you run the app with docker-compose build and docker-compose up and navigate to http://localhost/ you should see the default page:

Container Home Page

If you select an option however, or navigate directly to http://localhost/hello you'll notice that the response is not what you'd expect. Nothing happens. If you check the console or network inspector you should see the browser requesting our /apps/hello url, and responding with a 200, but it doesn't get rendered.

Turbo Frame responses #

We are missing a step, Turbo responses must container a turbo-frame with a matching id so that Turbo knows what to render and where.

So let's make this change to both apps:

<!-- This snippet will not compile as is, html comments are not currently supported in templ -->
<!-- src/goodbye/templates/goodbye.templ -->
{% package templates %}

{% templ GoodbyeTemplate(message string) %}
<turbo-frame id="app-container">
<p>{%= message %}</p>
</turbo-frame>
{% endtempl %}

<!-- src/hello/templates/hello.templ -->

{% package templates %}

{% templ HelloTemplate(message string) %}
<turbo-frame id="app-container">
<p>{%= message %}</p>
</turbo-frame>
{% endtempl %}

Run again #

Now if we run the app with docker-compose build and docker-compose up you should now be able to navigate between the apps, Turbo should render the frames correctly.

Congratulations, you've built your first MFEs web app!

Reference Code #

I've created a GitHub repo [3] for reference while following along.