Frontend
The browser only talks to one server — composing Marko, React, and Riot into one hotel page
Piyush Chauhan DEV Community
5 views
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
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
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
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/compiler → template.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?
The first paint does not wait for JavaScript.
HTML is the only lingua franca three frameworks share.
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>
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);
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;
}
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,
};
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:
Ephemeral UI state stays in the island. Filter chips, selected highlight, active day.
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.
Cross-fragment talk is an event, not a store import.
document.dispatchEvent(new CustomEvent('harborstay:hotel-selected', {
bubbles: true,
detail: { hotelId: 'moss-lantern' },
}));
Do not do this:
import { store } from '@harborstay/store'; // two bundles = two stores
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:
Publish content-hashed assets.
Roll fragment servers (any order — fallbacks cover restarts).
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 navigation — history.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
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
Read original: https://dev.to/frorning/the-browser-only-talks-to-one-server-composing-marko-react-and-riot-into-one-hotel-page-3355
Related
The Internet Lied to You About Portfolio Websites
Frontend
3
Dev.to (EN Zone)
Data Sharing between Threads
Frontend
6
Dev.to (EN Zone)
Comparing Caddy, nginx and Apache Configuration
Frontend
3
DEV Community
Why My React State Kept "Working" — Until Two Tabs Opened at Once
Frontend
2
Dev.to (EN Zone)
Comments0
No comments yet — be the first