Using Builder.io CMS content in a Micro Frontend setup

TL;DR

When you’re trying to integrate Builder.io CMS content inside an existing website using SSR technology (e.g. PHP), you can use a micro frontend setup using modern JavaScript technology: include the CMS content inside an <iframe> tag inside your existing (PHP) page and include your CMS content using an invisible iframe.

To render the CMS content at full scrollHeight, AFAIK, there is no “content completely rendered” event yet in a Builder Content component, but you can use browser events to emulate this functionality, see the steps below.

Steps

As in my original post, the main page is an existing PHP webpage (or similar SSR technologies). The Builder CMS content page is built separately using modern JavaScript technology (in this case a Vue.js page, but React, Svelte, … pages will work too).

In your PHP page, add the following JS code inside a <script> tag:

<!DOCTYPE html>
<html lang="nl">
  <head>
    <script type="text/javascript">
      window.addEventListener("message", (e) => {
        const data = e.data;
        if (data.msg == 'postScrollHeight') {
          console.log(`postScrollHeight ${data.scrollHeight} received at ${Date.now()}`)
          document.getElementById("ifrmBuilder").height = height; 
        }
      });
    </script>

    <style>
      iframe {
        display: block;
        background: #FFF;
        margin: 0;
        border: none;         /* Reset default border */
        width: 100%;
        overflow: hidden;
      }
    </style>
  </head>

  <body>
    <div>
      <div class="container-fluid pt-md-2">
        <div class="p-1">
          <iframe id="ifrmBuilder" src="http://localhost:5173" scrolling="no" height="100%"></iframe>
        </div>
      </div>  
    </div>
  </body>
</html>

You’ll notice the Vue.js page with the Builder CMS content is served from the default npm run dev url http://localhost:5173.

In your Vue.js file App.vue, Builder’s sdk-vue is used to display the CMS content:

<script setup>
import { Content, fetchOneEntry, isPreviewing, getBuilderSearchParams } from '@builder.io/sdk-vue';
import { onMounted, ref } from 'vue';
// polyfill ResizeObserver on older Apple Safari browsers
import ResizeObserverPF from 'resize-observer-polyfill';
// import debounce composable from @vueuse core lib
import { useDebounceFn } from '@vueuse/core';

import MyCounter from './components/MyCounter.vue';

// ponyfill ResizeObserver on Safari < 13.1 (don't polyfill on modern browsers)
if (!window.ResizeObserver) {
  console.log('ResizeObserver ponyfilled!')
  window.ResizeObserver = ResizeObserverPF
}

const content = ref(null);
const apiKey = '<your api key>';
const canShowContent = ref(false);
const model = '<your Builder content model>';
// an example Vue.js custom component MyCounter will show up in Builder's drag & drop Visual Editor:
const customComponents = [
  {
    component: MyCounter,
    name: 'My Counter',
    inputs: [
      {
        name: 'title',
        type: 'string'
      }
    ],
  }
]

// "debounce" callbacks for 100 ms to avoid the parent iframe resizing multiple times during async CMS content rendering
// send a message to the parent page window
const debouncedResizeFn = useDebounceFn((entries) => {
  for (const entry of entries) {
    console.log(`resize scrollHeight: ${entry.target.scrollHeight} at ${Date.now()}`)
    if (window.parent) {
      window.parent.postMessage({
        msg: 'postScrollHeight',
        scrollHeight: entry.target.scrollHeight
      }, '*')
    }
  }
}, 100)

onMounted(async () => {
  content.value = await fetchOneEntry({
    model,
    apiKey,
    options: getBuilderSearchParams(
      new URL(location.href).searchParams
    ),
    userAttributes: {
      urlPath: window.location.pathname,
    },
  });

  // create a ResizeObserver instance to trap body resize events when the CMS content is rendered
  const resizeObserver = new ResizeObserver(debouncedResizeFn);
  // observe the document body of the Vue.js app
  resizeObserver.observe(document.body);

  canShowContent.value = content.value ? true : isPreviewing();
});
</script>

<template>
  <h1>Builder.io CMS Vue demo</h1>

  <Content
    v-if="canShowContent"
    :model="model"
    :content="content"
    :api-key="apiKey"
    :customComponents="customComponents"
  />
  <div v-else>
    Content loading (or loading failed) ...
  </div>
</template>

<style scoped>
</style>

Btw, if you’re wondering why I’m using a ResizeObserver instance: you can’t use Vue’s nextTick() method because the Content component is still rendering the content asynchronously (changing the scrollHeight) after the fetchOneEntry() is complete and setting canShowContent.value.

FYI, the very simple MyCounter example component:

<script setup>
import { ref } from 'vue'

defineProps({
  title: {
    type: String,
    required: true
  }
})

const count = ref(0)

</script>

<template>
  <div class="bold">{{ title }} is: {{ count }}</div>
</template>

<style scoped>
.bold {
  font-weight: bold;
}
</style>

This Micro Frontend integration method using iframes can help in gradually modernizing existing websites using other technologies: the CMS content can use all functionality of the latest JS tech stacks & you can include the JS app in existing webpages. Using windows.postMessage(), you can communicate between the parent page and the iframe container.

2 Likes

Hey @wdbacker, really appreciate this post.

Coincidentally, I’m currently reading “Micro Frontends in Action”, and the walkthrough from both of your posts have been a great opportunity to think about your approach, contrasting it to alternative architectural situations presented in the book.

To render the CMS content at full scrollHeight, AFAIK, there is no “content completely rendered” event yet in a Builder Content component, but you can use browser events to emulate this functionality, see the steps below.

There’s a clean solution to this by using Custom Elements, which would resize to their content naturally. Since you’re using Vue, and your component is an SFC, I’d suggest you to have a look at: Vue and Web Components | Vue.js.

The biggest downside to this approach comes from the constraint of Custom Elements being a client-side only abstraction. If your Vue comes from Nuxt or any other meta-framework that has a server rendering your content, the iframe is your best (and, possibly, only) option, as it loads from an URL.

More on Custom Elements (aka Web Components):