DEV Community

Cover image for The browser only talks to one server — composing Marko, React, and Riot into one hotel page
Piyush Chauhan
Piyush Chauhan

Posted on

The browser only talks to one server — composing Marko, React, and Riot into one hotel page

You open a hotel page. It looks like one product: a search grid, a featured stay, local highlights, reviews, a sticky trip summary.

Under the hood it is eight HTTP servers and three UI runtimes.

That is the experiment behind HarborStay, a demo booking app I built to answer a stubborn question:

Can independent teams ship independent UI, in independent frameworks, and still give the browser a single, paint-ready HTML page?

The punchline: yes — if the shell never imports a component. It only fetches HTML.

The one rule

The browser never talks to a fragment.

It talks to the composer on port 3100. The composer owns routes, layout, and the booking flow. Everything else is a fragment server that returns a chunk of HTML.

flowchart LR
  Browser["Browser"] --> Composer["Composer :3100"]

  subgraph fragments["Fragment servers"]
    Nav["Navigation Marko :3101"]
    Search["Hotel search Marko :3102"]
    Details["Hotel details Marko :3103"]
    Reviews["Reviews Marko :3104"]
    Recs["Recommendations Marko :3105"]
    Highlights["Local highlights React :3106"]
    Disco["Experiences discovery Riot :3107"]
    Itin["Experiences itinerary Riot :3108"]
  end

  Composer --> Nav
  Composer --> Search
  Composer --> Details
  Composer --> Reviews
  Composer --> Recs
  Composer --> Highlights
  Composer --> Disco
  Composer --> Itin
  Composer --> CDN["CDN :3200"]

This is the opposite of the usual microfrontend story (Module Federation, shared React, a host that import()s widgets). HarborStay is HTML composition. The shell does not know whether a fragment was rendered by Marko, React, or a hand-rolled Riot string. It only knows a URL.

That one constraint buys a lot:

  • Fragment teams can pick a runtime without asking the shell.
  • A fragment outage becomes a fallback box, not a blank page.
  • You can deploy search without redeploying reviews.

It also forces honesty. If two fragments need to share a Redux store, the architecture is already leaking.

What the user actually sees

HarborStay models a small premium catalog: Harbor View Lodge in Lisbon, Copper Hill Suites in Seville, Sunset Terrace House in Barcelona, Moss & Lantern in Copenhagen.

Routes the composer owns:

Route What you get
/ and /hotel/:hotelId Streaming stays page
/experiences/:hotelId Neighborhood explorer + day plan
/booking/confirm/:hotelId Confirmation (I got HS-470455 for Moss & Lantern)

On /hotel/moss-lantern the page is not “a React tree.” It is a layout of slots:

flowchart TB
  subgraph page["One HTML page from the composer"]
    Topbar["Topbar: HarborStay + Stays / Experiences"]
    subgraph main["Main column"]
      Search["Search island: Where to next?"]
      Details["Details island: Featured stay"]
      Highlights["Highlights island: What to do in Copenhagen"]
      Reviews["Reviews island: What travelers say"]
      Recs["Recommendations island: More stays nearby"]
    end
    Sidebar["Sticky trip summary + Book this stay"]
  end

Click Luxury on the search chips and the grid collapses to Harbor View Lodge and Moss & Lantern. The reviews below do not re-fetch. They never heard about the filter. That is not a bug — that is the point.


Island rule: each fragment owns its own interactivity. Search filters are Marko state. Highlight selection is React state. Experience categories are Riot state. The composer does not have a global store.

A tiny protocol instead of a shared SDK

Every fragment speaks harborstay-fragment/v1.

GET /?hotelId=moss-lantern
GET /manifest
GET /assets/<name>.css
GET /assets/<name>.js
Enter fullscreen mode Exit fullscreen mode

And a few provenance headers:

X-Fragment-Name: search
X-Fragment-Hotel-Id: moss-lantern
X-Fragment-Protocol: harborstay-fragment/v1
X-Fragment-Css: http://localhost:3200/assets/search.css
X-Fragment-Js:  http://localhost:3200/assets/search.js
Enter fullscreen mode Exit fullscreen mode

The composer reads the HTML body. Asset URLs are informational. In this demo, CSS and JS are served from a tiny CDN app on :3200 that copies fragment assets on startup.

That split is the real-world shape: SSR lives with the team that owns the UI. Bytes live on a CDN.

Assets belong to the fragment that created them, not to the shell:

apps/hotel-search/src/assets/search.css
apps/hotel-search/src/build-client.js   → cdn/public/assets/search.js
Enter fullscreen mode Exit fullscreen mode

If search wants a new filter chip, reviews does not rebuild.

Three runtimes, one HTML contract

This is the part people assume is impossible.

Runtime Fragments Server render Client
Marko navigation, search, details, reviews, recommendations @marko/compilertemplate.render().toString() Marko DOM runtime mounts into a root
React 19 local highlights react-dom/server renderToStaticMarkup hydrateRoot
Riot experiences discovery + itinerary hand-written renderSsrMarkup() riot.component() after clearing the SSR root

The composer does not care. All three return strings.

flowchart TB
  subgraph marko["Marko :3102"]
    M1["hotel-search.marko"] --> M2["HTML string"]
  end
  subgraph react["React :3106"]
    R1["HighlightsView.jsx"] --> R2["HTML string"]
  end
  subgraph riot["Riot :3107"]
    T1["renderSsrMarkup()"] --> T2["HTML string"]
  end
  M2 --> Composer["Composer stitches slots"]
  R2 --> Composer
  T2 --> Composer
  Composer --> Browser["Browser paints HTML before any island JS"]

Why SSR in a microfrontend at all?

  1. The first paint does not wait for JavaScript.
  2. HTML is the only lingua franca three frameworks share.
  3. If experiences-discovery.js fails, the user still sees the cards.

That last one is Islands architecture in the small: static HTML with pockets of JS, not a SPA that hydrates the entire document.

How data crosses the SSR/client gap

Every interactive fragment embeds props as JSON, not data- attributes:

<script type="application/json" id="hotel-search-fragment-data">
  {"hotels":[...],"hotelId":"moss-lantern"}
</script>
<div id="hotel-search-fragment-root">
  <!-- SSR HTML -->
</div>
Enter fullscreen mode Exit fullscreen mode

The client bundle reads that script and mounts. Before embedding, fragment servers escape < as \u003c so a hotel description cannot break out of the script tag.

data-hotels='[{...}]' looks simpler. It is worse: large JSON in attributes bloats the DOM, needs ugly escaping, and is visible to everything that walks the tree.

Hydration is not one word

Walking the live app made this concrete.

Marko search hydrates into #hotel-search-fragment-root. Luxury / Value / Top rated re-render the grid without a navigation. The client template must match the server template or you get a flash.

React highlights hydrates with hydrateRoot. On Moss & Lantern you get Garden loop, Sauna ritual, Harbor market. Click a card and only that island updates.

Riot cannot hydrate the way React does. There is no reconcile-existing-DOM API in this setup, so the client wipes the SSR HTML and remounts:

mountNode.innerHTML = '';
component(DiscoveryTag)(mountNode, props);
Enter fullscreen mode Exit fullscreen mode

You still get a fast first paint. You also get a tiny risk of visual flash if the Riot template and renderSsrMarkup() drift. Keep class names, element types, and copy identical.

On /experiences/moss-lantern I clicked Food: three cards became one tasting session. I clicked Day 2: breakfast / walk / dinner became scenic run / museum / sunset lounge. Two Riot islands, two state machines, zero shared store.

sequenceDiagram
  participant U as User
  participant D as Discovery Riot
  participant I as Itinerary Riot
  U->>D: Click Food
  D->>D: state.activeCategory = Food
  Note over I: Itinerary does not care
  U->>I: Click Day 2
  I->>I: state.selectedDay = 2
  Note over D: Discovery does not care

Streaming: do not wait for the slowest fragment

The stays page does not await Promise.all then send one blob.

streamPage() is an async function* . It yields the <head> and shell immediately, then races fragment promises and flushes HTML as each one finishes.

yield headPrefix;

const pending = new Map(
  fragmentOrder.map((name) => [name, safeRenderFragment(name, hotelId, delay)])
);

while (pending.size > 0) {
  const next = await Promise.race(
    [...pending.entries()].map(([name, promise]) =>
      promise.then((fragment) => ({ name, fragment }))
    )
  );
  pending.delete(next.name);
  yield next.fragment.html;
}
Enter fullscreen mode Exit fullscreen mode

Fastify wraps that in Readable.from(...). The response header x-ssr-stream: true is a breadcrumb that this is a streamed document.

gantt
  title Streaming vs waiting for everyone
  dateFormat  X
  axisFormat  %s ms
  section Buffered
  Wait for slowest fragment     :a1, 0, 860
  First paint                   :a2, 860, 50
  section Streamed
  Head + shell                  :b1, 0, 40
  Search HTML                   :b2, 160, 40
  Recommendations HTML          :b3, 300, 40
  Details HTML                  :b4, 540, 40
  Reviews HTML                  :b5, 860, 40

The demo even fakes latency so you can see streaming:

const FRAGMENT_STREAM_DELAY_MS = {
  search: 160,
  recommendations: 300,
  details: 540,
  reviews: 860,
};
Enter fullscreen mode Exit fullscreen mode

In production you delete those delays. Keep the race. First Contentful Paint becomes “when the shell arrives,” not “when reviews finally woke up.” Skeletons (.loading-fragment) hold layout so later chunks do not shove the page around.

If a fragment is restarting, safeRenderFragment catches the error and yields:

reviews fragment is warming up.

Independent deploys stop being theoretical.

JavaScript budget is a route problem

A composed page can accidentally ship four frameworks to every visitor. HarborStay avoids that with page splits, not heroics.

  • pnpm dev:home runs composer + CDN + stays fragments.
  • pnpm dev:experiences runs composer + CDN + the two Riot apps.
  • The stays page builder never emits experiences-discovery.js.
  • The experiences page never emits search.js.

That is the cheapest performance win in the repo. Intersection Observer hydration, click-to-hydrate, and requestIdleCallback are next — the docs already sketch them — but route-level omission beats all of them.

flowchart LR
  StayPage["/hotel/:id"] --> StayJS["search.js + highlights.js"]
  ExpPage["/experiences/:id"] --> ExpJS["discovery.js + itinerary.js"]
  StayPage -.-> NoRiot["Riot bundles not referenced"]
  ExpPage -.-> NoMarko["Search bundle not referenced"]

State: URL, local, or an event — pick one

Shared mutable global state is how microfrontends become a distributed monolith.

HarborStay’s rules:

  1. Ephemeral UI state stays in the island. Filter chips, selected highlight, active day.
  2. Shareable state lives in the URL. /hotel/moss-lantern is the source of truth for “which stay.” Full page reload is intentional in this demo.
  3. Cross-fragment talk is an event, not a store import.
document.dispatchEvent(new CustomEvent('harborstay:hotel-selected', {
  bubbles: true,
  detail: { hotelId: 'moss-lantern' },
}));
Enter fullscreen mode Exit fullscreen mode

Do not do this:

import { store } from '@harborstay/store'; // two bundles = two stores
Enter fullscreen mode Exit fullscreen mode

Two IIFE bundles that “import the same store” get two closures. They will not share state. They will gaslight you.

BroadcastChannel is the right tool only when tabs or iframes must stay in sync. localStorage is for values that must survive a round trip. Neither belongs in the first version.

How this would ship

Each fragment is a Node HTTP server. DNS for users points at the composer. Fragments live on internal hostnames. Static assets go to a real CDN.

flowchart TB
  User["User"] --> LB["Load balancer / CDN"]
  LB --> Composer["Composer"]
  Composer --> F1["mfe-hotel-search"]
  Composer --> F2["mfe-reviews"]
  Composer --> F3["mfe-local-highlights"]
  Composer --> F4["other fragments"]
  Assets["cdn.example.com"] -.-> User

Rolling deploy order:

  1. Publish content-hashed assets.
  2. Roll fragment servers (any order — fallbacks cover restarts).
  3. Roll the composer last.

Turborepo is the monorepo glue: pnpm dev:home fans out with persistent dev tasks, and turbo run build only rebuilds what changed.

This is a demo, not a production platform. The goal is a shape you can reason about: one contract, many runtimes, progressive HTML.

What I would do next

The interesting unfinished work is not “add another framework.” It is:

  • Soft navigationhistory.pushState + swap only the fragments that changed.
  • True streaming inside fragments — Marko template.stream(), React renderToPipeableStream.
  • Riot hydrate() when the API is stable, so we stop wiping SSR DOM.
  • Edge composer — the generator already looks like a Web Streams mental model.
  • SWR cache of fragment HTML at the composer for anonymous hotel pages.

Run it

pnpm install
pnpm dev:home          # stays flow
pnpm dev:experiences   # experiences flow
pnpm dev:all           # everything
Enter fullscreen mode Exit fullscreen mode

Then open http://localhost:3100.

If you want a mental model to steal, steal this one:


Compose HTML, not components. Hydrate islands, not pages. Keep state with the team that owns the UI. Let the slowest fragment arrive last without blocking the first paint.

The user still sees one hotel page. The architecture is allowed to be many rooms behind one door.

GITHUB: https://github.com/piyushchauhan2011/marko-mfe

Top comments (0)