본문으로 건너뛰기
오늘의 흐름
Frontenddev.to··원문 약 2

브라우저는 하나의 서버와만 통신합니다. 즉, Marko, React, Riot를 하나의 호텔 페이지로 구성합니다.

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

호텔 페이지를 엽니다.

브라우저는 하나의 서버와만 통신합니다. 즉, Marko, React, Riot를 하나의 호텔 페이지로 구성합니다. 대표 이미지

핵심 요약

자동 요약
  1. 1호텔 페이지를 엽니다.
  2. 2검색 그리드, 추천 숙박, 지역 하이라이트, 리뷰, 끈적한 여행 요약 등 하나의 제품처럼 보입니다.
  3. 3내부적으로는 8개의 HTTP 서버와 3개의 UI 런타임이 있습니다.

원문 본문

출처 · dev.to

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/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> 

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:

  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' }, })); 

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:

  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 

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

For further actions, you may consider blocking this person and/or reporting abuse

이 글은 dev.to 의 원문을 정제해 보여드립니다. 저작권은 원저작자에게 있습니다.

#microfrontends#ssr#webperf#architecture

전체 내용이 궁금하다면

dev.to 원문에서 이어 읽기

원문 보기

비슷한 글

5유사도 추천