Core Concepts
HTML Builders
A view is built by chaining method calls. There are four kinds of builder methods, and knowing what each one returns is the key to reading and writing HtmlFlow fluently:
- Element builders (
html(),body(),div(),p(), …) open a new element and return that child element, so the next call nests inside it. - Attribute builders (
attrClass(...),attrSrc(...),attrHref(...), …) set an attribute and return the same element, so you can keep chaining attributes. - Text builders (
.text(...),.raw(...)) add a text node and return the parent element;.text(...)escapes its content, while.raw(...)emits it unescaped. - Close builder (
.__()in Java,.lin Kotlin) emits the element's end tag and returns its parent, stepping back out one level.
- Java
- Kotlin
HtmlFlow.doc(System.out)
.html()
.body()
.div().attrClass("card") // element builder + attribute builder
.h1().text("Title").__() // text returns the <h1>, __() closes it
.__() // closes the <div>
.__() // closes the <body>
.__(); // closes the <html>
import htmlflow.*
import org.xmlet.htmlapifaster.*
System.out.doc {
html {
body {
div {
attrClass("card")
h1 { text("Title") }
} // div
} // body
} // html
}
In Java the .__() calls make the nesting explicit. The trailing // div comments are a
common convention for tracking which element each close belongs to.
In Kotlin the trailing lambda braces close elements for you, and .l is a shorthand for
.__() when chaining without a block.
HTML Templates
HtmlFlow offers two ways to define a template, differing in when the HTML is produced.
HtmlFlow.doc(out)→HtmlDoc(eager). The markup is written to theAppendableimmediately, as you chain the builders. Best for a document you render once.HtmlFlow.view(template)→HtmlView<M>(lazy, reusable). The template, anHtmlTemplatelambda, is defined once and rendered many times, each time with a different modelM.
- Java
- Kotlin
// Lazily builds a reusable view. Created once and rendered multiple times.
static HtmlView<String> view = HtmlFlow.view(page -> page
.html().body().h1().<String>dynamic((h1, name) -> h1.text("Welcome " + name)).__().__().__());
static void printTwice(String name) {
// Builds and renders on the fly.
HtmlFlow.doc(System.out)
.html().body().h1().text("Welcome " + name).__().__().__();
// Binds and renders a pre-encoded view.
view.setOut(System.out).write(name);
}
import htmlflow.*
import org.xmlet.htmlapifaster.*
// Lazily builds a reusable view. Created once and rendered multiple times.
val view = view<String> {
html { body { h1 { dyn { name: String -> text("Welcome $name") } } } }
}
fun printTwice(name: String) {
// Builds and renders on the fly.
System.out.doc {
html { body { h1 { text("Welcome $name") } } }
}
// Binds and renders the pre-encoded view.
view.setOut(System.out).write(name)
}
An HtmlView separates static HTML (which never changes) from dynamic HTML (which
depends on the model). The static blocks are resolved only once, when the view is
instantiated, and reused on every subsequent render. Binding the same template to many
models, the typical web-server workload, therefore avoids recomputing the parts that
never change.
Models and Data Binding
A template renders dynamic data through a model object. In a reusable view the model
is passed to write(model) or render(model), and you read it inside a dynamic block:
.dynamic((element, model) -> ...): used in anHtmlView; receives both the parent element and the model. In Kotlin this is.dyn { model -> ... }..of(element -> ...): used in an eagerHtmlDoc; receives only the element (the data is captured directly from the surrounding scope).
- Java
- Kotlin
record Track(String artist, String name) {}
HtmlView<Track> trackView = HtmlFlow.view(page -> page
.html()
.body()
.ul()
.<Track>dynamic((ul, track) -> ul
.li().text("Artist: " + track.artist()).__()
.li().text("Track: " + track.name()).__())
.__() // ul
.__() // body
.__() // html
);
// Render to a String...
String html = trackView.render(new Track("David Bowie", "Space Oddity"));
// ...or write straight to an Appendable
trackView.setOut(System.out).write(new Track("David Bowie", "Space Oddity"));
import htmlflow.*
data class Track(val artist: String, val name: String)
val trackView = view<Track> {
html {
body {
ul {
dyn { track: Track ->
li { text("Artist: ${track.artist}") }
li { text("Track: ${track.name}") }
}
} // ul
} // body
} // html
}
val html = trackView.render(Track("David Bowie", "Space Oddity"))
Only content inside a dynamic block is re-evaluated on each render; everything outside it is treated as a static block and emitted as-is. This split has a direct performance consequence: keep dynamic blocks as small as possible.
Anything inside a dynamic / dyn block is rebuilt by re-walking the builders on every
render — even tags and attributes that never change. Anything outside is pre-encoded once
and reused.
So put only the model-dependent values inside the block, and leave the surrounding structure static.
When the structure itself depends on the model, see Pre-encoding model-driven structure below.
Conditional Rendering and Loops
HtmlFlow has no special syntax for conditionals or iteration: you use ordinary Java or
Kotlin control flow inside a dynamic (.dynamic / .dyn) or .of block. An if
statement conditionally adds elements; a forEach loop repeats them.
- Java
- Kotlin
HtmlView<List<Track>> playlistView = HtmlFlow.view(page -> page
.html()
.body()
.table()
.tr()
.th().text("Artist").__()
.th().text("Track").__()
.__() // tr
.<List<Track>>dynamic((table, tracks) -> tracks.forEach(trk -> table
.tr()
.td().text(trk.artist()).__()
.td().text(trk.name()).__()
.__() // tr
))
.__() // table
.__() // body
.__() // html
);
// Conditional: only render the <li> when the value is present
.<Track>dynamic((ul, track) -> {
if (track.diedDate() != null)
ul.li().text("Died in " + track.diedDate().getYear()).__();
})
val playlistView = view<List<Track>> {
html {
body {
table {
tr {
th { text("Artist") }
th { text("Track") }
}
dyn { tracks: List<Track> ->
tracks.forEach { track ->
tr {
td { text(track.artist) }
td { text(track.name) }
}
}
}
} // table
} // body
} // html
}
// Conditional
dyn { track: Track ->
if (track.diedDate != null) {
li { text("Died in ${track.diedDate.year}") }
}
}
Pre-encoding model-driven structure
Sometimes the HTML structure, not just a value, depends on the model: a loop body, or a branch that emits different markup.
That markup has to live inside a dynamic block. By the rule above it is re-walked on every render — and, in a loop, on every iteration — rebuilding its static tags each time.
You can recover the pre-encoding. Extract the model-driven fragment into its own reusable
HtmlView, created once at module level, and render it where you need it with raw(...).
The fragment's static HTML is then encoded once at instantiation and reused on every call, so each render only binds the dynamic values. A repeated table row is the clearest example:
- Java
- Kotlin
// Created once — static <tr>/<td> structure is pre-encoded a single time
static final HtmlView<Track> rowView = HtmlFlow.view(page -> page
.tr()
.<Track>dynamic((tr, t) -> tr.td().text(t.artist()).__())
.<Track>dynamic((tr, t) -> tr.td().text(t.name()).__())
.__());
static final HtmlView<List<Track>> playlistView = HtmlFlow.view(page -> page
.html().body().table()
.<List<Track>>dynamic((table, tracks) ->
tracks.forEach(t -> table.raw(rowView.render(t))))
.__().__().__());
val rowView = view<Track> {
tr {
dyn { t: Track -> td { text(t.artist) } }
dyn { t: Track -> td { text(t.name) } }
}
}
val playlistView = view<List<Track>> {
html { body { table {
dyn { tracks: List<Track> ->
tracks.forEach { t -> raw(rowView.render(t)) }
}
} } }
}
This pays off only with pre-encoding enabled (the default), and only when the fragment holds
enough static markup to earn back the extra render(...) call. The gain comes from reusing the
fragment's pre-encoded static blocks instead of re-walking them.
Keep the fragment view in a static final field, or a top-level val in Kotlin. Creating it
inside the loop, or inside the enclosing template, re-runs pre-encoding every time and erases
the benefit.
Configuring Views
A view's behaviour is configured through HtmlFlow.ViewFactory. The builder exposes the
common options; view(...)/doc(...) use sensible defaults when called without a factory.
- Java
- Kotlin
HtmlView<Model> view = HtmlFlow.ViewFactory.builder()
.indented(true) // pretty-print output (default true)
.threadSafe(false) // make the view safe to share across threads (default false)
.preEncoding(true) // pre-encode static blocks for faster rendering (default true)
.mfeEnabled(false) // enable the mfe() micro-frontend builder (default false)
.build()
.view(template);
The Kotlin DSL's view { } uses defaults; to change them, build the view through
the same ViewFactory:
val view: HtmlView<Model> = HtmlFlow.ViewFactory.builder()
.indented(true) // pretty-print output (default true)
.threadSafe(false) // make the view safe to share across threads (default false)
.preEncoding(true) // pre-encode static blocks for faster rendering (default true)
.mfeEnabled(false) // enable the mfe() micro-frontend builder (default false)
.build()
.view(template)
HtmlDoc and HtmlView never mutate in place. Configuration methods such as
setIndented(boolean), threadSafe(), and setOut(Appendable) return a new instance
with the change applied, so assign the result rather than relying on a side effect.
Indentation is on by default; turn it off in production to emit minified HTML. Views are
not thread-safe by default. Call threadSafe() (or set threadSafe(true)) before
sharing one view across request threads.
Reusable Components
Templates compose. A fragment (also called a partial) is just a function that takes a
parent element and adds children to it: a Consumer of an HTML element in Java, or a
receiver function in Kotlin.
Because it accepts any element, not only the page root, the same fragment can be reused anywhere that element type is valid.
- Java
- Kotlin
// A reusable input-field fragment
static void inputField(Div<?> container, String label, String id, Object value) {
container
.div().attrClass("form-group")
.label().text(label).__()
.input()
.attrClass("form-control")
.attrType(EnumTypeInputType.TEXT)
.attrId(id).attrName(id)
.attrValue(value.toString())
.__() // input
.__(); // div
}
// A template that builds a form from a reusable fragment
public static HtmlView<Owner> ownerView() {
return HtmlFlow.view(page -> page
.html()
.body()
.form().attrMethod(EnumMethodType.POST)
.<Owner>dynamic((form, owner) -> {
inputField(form.div(), "Name", "name", owner.getName());
inputField(form.div(), "Address", "address", owner.getAddress());
})
.__() // form
.__() // body
.__() // html
);
}
import htmlflow.*
// A reusable input-field fragment
fun Div<*>.inputField(label: String, id: String, value: Any) {
div {
attrClass("form-group")
label { text(label) }
input {
attrClass("form-control")
attrType(EnumTypeInputType.TEXT)
attrId(id); attrName(id)
attrValue(value.toString())
} // input
} // div
}
// A layout template that calls the fragment
fun ownerView(): HtmlView<Owner> = view {
html {
body {
form {
attrMethod(EnumMethodType.POST)
dyn { owner: Owner ->
inputField("Name", "name", owner.name)
inputField("Address", "address", owner.address)
}
} // form
} // body
} // html
}
When a fragment depends on the model, invoke it from inside a dynamic() / dyn block, as
above, not from of(). Content added via of() is captured once as a static block, so
a model-dependent fragment placed there would render only the first model's data and reuse
it forever.
Layouts
Fragments also compose into layouts. A layout is an ordinary template with "holes": rather
than fixed markup, it accepts fragments as parameters (Consumer<Nav<?>>, Consumer<Div<?>>, …)
and drops each one into place with the .of(fragment::accept) builder.
.of(navbar::accept) is shorthand for .of(nav -> navbar.accept(nav)): it chains a consumer of
the most recently created element, so the fragment fills exactly that spot.
- Java
- Kotlin
// A layout with "navbar" and "content" holes
static HtmlView<Owner> layout(Consumer<Nav<?>> navbar, Consumer<Div<?>> content) {
return HtmlFlow.view(page -> page
.html()
.head()
.title().text("PetClinic").__()
.__() // head
.body()
.nav().of(navbar::accept).__() // fill the navbar hole
.div().attrClass("container")
.of(content::accept) // fill the content hole
.__() // div
.__() // body
.__() // html
);
}
// Build a concrete view by passing fragments into the layout
HtmlView<Owner> ownerView = layout(
nav -> nav.a().attrHref("/").text("Home").__(),
div -> div.h1().text("Owner").__());
import htmlflow.*
// A layout with "navbar" and "content" holes
fun layout(navbar: Nav<*>.() -> Unit, content: Div<*>.() -> Unit): HtmlView<Owner> =
view {
html {
head { title { text("PetClinic") } }
body {
nav { navbar() } // fill the navbar hole
div {
attrClass("container")
content() // fill the content hole
}
}
}
}
// Build a concrete view by passing fragments into the layout
val ownerView = layout(
navbar = { a { attrHref("/"); text("Home") } },
content = { h1 { text("Owner") } })
Because the layout is itself a reusable HtmlView, its static skeleton (head, nav/div
structure) is pre-encoded once; only the inserted fragments change between the views you build
from it.
Micro-Frontends
A micro-frontend architecture splits a page into HTML fragments owned by independent services.
A shell (view) page can be created with the new mfe() builder.
This builder embeds a micro-frontend based on metadata set in the configuration object.
Each HTML fragment is fetched remotely from the URL configured in the metadata.
The composition of the micro-frontends is done by a client side
script that is required to be set in the view.
The mfe feature requires a view built with mfeEnabled(true).
Setup
- Build the shell view with
mfeEnabled(true). - Add the HtmlFlow MFE engine in
<head>element of the page. - Declare the micro-frontends in the view with
mfe()method.
- Java
- Kotlin
static final String ENGINE =
"https://cdn.jsdelivr.net/gh/xmlet/HtmlFlow@master/htmlflow-mfe-engine/dist/htmlflow-mfe-engine.js";
HtmlView<?> shell = HtmlFlow.ViewFactory.builder()
.mfeEnabled(true)
.build()
.view(page -> page
.html()
.head()
.script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE).__()
.__() // head
.body()
.mfe(cfg -> cfg
.setMfeUrlResource("http://localhost:8081/bikes/view")
.setMfeName("bikes")
.__() // body
.__() // html
);
The Kotlin DSL has no sugar for mfeEnabled, so build the view through the same
ViewFactory; mfe { } is then available inside any element block:
const val ENGINE =
"https://cdn.jsdelivr.net/gh/xmlet/HtmlFlow@master/htmlflow-mfe-engine/dist/htmlflow-mfe-engine.js";
val shell: HtmlView<*> = HtmlFlow.ViewFactory.builder()
.mfeEnabled(true)
.build()
.view<Any> { page ->
page.html {
head {
script { attrType(EnumTypeScriptType.MODULE); attrSrc(ENGINE) }
}
body {
mfe { cfg ->
cfg.setMfeUrlResource("http://localhost:8081/bikes/view")
cfg.setMfeName("bikes")
}
}
}
}
The engine fetches fragments and stylesheets from the browser, therefore services on another origin must allow CORS requests from the shell.
When configuring a micro-frontend the url and the name are mandatory (see MFE Configuration).
Rendering the view shell.render() produces the placeholder markup:
<html>
<head>
<script type="module" src="https://cdn.jsdelivr.net/.../htmlflow-mfe-engine.js"></script>
</head>
<body>
<micro-frontend mfe-url="http://localhost:8081/bikes/view" mfe-name="bikes"></micro-frontend>
</body>
</html>
The micro-frontend mechanism expects any HTML fragments. It can come from HtmlFlow, Thymeleaf, or any other template engine as long as HTML is provided. Is it possible to combine server-side rendering (SSR) and client-side rendering (CSR) fragments, an example is shown in the CSR-SSR example.
Micro-frontend configuration
The configuration object passed to the mfe method has the following properties:
| Option | Purpose |
|---|---|
setMfeUrlResource(url) required | URL that represents the location of HTML fragment. |
setMfeName(name) required | Unique name of the micro-frontend, used by window.mfe(...) in scripts. |
setMfeScriptUrl(url) | Fragment script used for hydration, added to <head> as <script type="module">. |
setMfeStylingUrl(url) | Stylesheet scoped to this fragment. |
setMfeSharedStylingUrl(url) | Theme stylesheet shared by all fragments and the shell. (see Styling |
setMfeTriggersEventName(name) | Default event name that this fragment emits. |
setMfeListeningEventName(name) | Default event name that this fragment listens to. |
setMfeStreamingData(true) | Render the fragment progressively from stream endpoints (see Streaming). |
setMfeScriptIntegrity(hash) | SRI hash for the fragment script (see Script integrity). |
Leaving out a required option throws an exception when the view is built.
Hydration of fragments
The entry point for each micro-frontend hydration is window.mfe(name, callback).
It register a callback that fires when the MFE fragment is mounted in the DOM.
Because the fragment lives in a Shadow DOM, you can query its elements through root:
// mfe-bikes.js
mfe('bikes', ({ root, triggerMfeEvent, listenMfeEvent, reloadMfe }) => {
root.querySelectorAll('button').forEach(btn => btn.addEventListener('click', () => console.log('clicked', btn.id)));
});
The callback receives a context object with the following properties that allow interaction with the micro-frontend:
| Context member | Description |
|---|---|
root | The fragment's shadow root. |
triggerMfeEvent(message, payload, [event]) | Publish an event. Without event, it uses setMfeTriggersEventName. |
listenMfeEvent(listener, [event]) | Subscribe to an event. Without event, it uses setMfeListeningEventName. |
reloadMfe() | Fetch and render the fragment again. |
Use the reloadMfe() method to refresh the fragment and keep the UI in sync after mutating state in the backend.
Communication between micro-frontends
Micro-frontends communicate through an internal event bus managed by the engine leveraging DOM events. Use setMfeListeningEventName to register as a listener with the respective name, and the setMfeTriggersEventName to trigger events with the respective name. To establish a two way communication between the micro-frontends use the same event name in both configurations. In the example bellow, the bikes fragment publishes an event when a bike is added and the cart fragment listens for it.
- Java
- Kotlin
.mfe(cfg -> cfg
.setMfeUrlResource("http://localhost:8081/bikes/view")
.setMfeName("bikes")
.setMfeTriggersEventName("cartEvent")
.setMfeScriptUrl("http://localhost:8081/js/mfe-bikes.js"))
.mfe(cfg -> cfg
.setMfeUrlResource("http://localhost:8082/cart/view")
.setMfeName("cart")
.setMfeListeningEventName("cartEvent")
.setMfeScriptUrl("http://localhost:8082/mfe-cart.js"))
mfe { cfg ->
cfg.setMfeUrlResource("http://localhost:8081/bikes/view")
cfg.setMfeName("bikes")
cfg.setMfeTriggersEventName("cartEvent")
cfg.setMfeScriptUrl("http://localhost:8081/js/mfe-bikes.js")
}
mfe { cfg ->
cfg.setMfeUrlResource("http://localhost:8082/cart/view")
cfg.setMfeName("cart")
cfg.setMfeListeningEventName("cartEvent")
cfg.setMfeScriptUrl("http://localhost:8082/mfe-cart.js")
}
After setting the communication between micro-frontends, use the triggerMfeEvent and listenMfeEvent methods
from the context object to send and receive events between them.
// mfe-bikes.js: publish an event
mfe('bikes', ({ root, triggerMfeEvent }) => {
root
.querySelectorAll('button[data-id]')
.forEach(btn => btn.addEventListener('click', () => triggerMfeEvent('add to cart', { id: btn.dataset.id })));
});
// mfe-cart.js: react to the event, then refresh the fragment
mfe('cart', ({ listenMfeEvent, reloadMfe }) => {
listenMfeEvent(async e => {
await fetch(`http://localhost:8082/cart/add/${e.detail.payload.id}`, { method: 'POST' });
reloadMfe();
});
});
The listener receives a custom event whose detail is { message, payload } object. When a fragment
reloads, the engine removes the listeners registered to preventing duplications.
Styling
Each fragment is isolated using Shadow DOM preventing styles leakage between fragments or into the
shell. Use setMfeStylingUrl for fragment-specific styles and setMfeSharedStylingUrl if you need
to to share styles between fragments.
setMfeStylingUrlapplies a stylesheet to this fragment only.setMfeSharedStylingUrlapplies a common theme, such as colours, fonts, and CSS variables, to the fragment and the shell document. Each URL is fetched once and reused by every fragment that references it.
- Java
- Kotlin
.mfe(cfg -> cfg
.setMfeUrlResource("http://localhost:8081/bikes/view")
.setMfeName("bikes")
.setMfeStylingUrl("http://localhost:8081/css/style.css")
.setMfeSharedStylingUrl("http://localhost:8080/shared-theme.css"))
mfe { cfg ->
cfg.setMfeUrlResource("http://localhost:8081/bikes/view")
cfg.setMfeName("bikes")
cfg.setMfeStylingUrl("http://localhost:8081/css/style.css")
cfg.setMfeSharedStylingUrl("http://localhost:8080/shared-theme.css"))
}
You can see an example of a shared theme in the micro-frontends theming example
Streaming fragments
With setMfeStreamingData(true), the engine renders the fragment response chunk by chunk, so items
appear while the service is still producing them.
- Java
- Kotlin
.mfe(cfg -> cfg
.setMfeUrlResource("http://localhost:8081/bikes/stream")
.setMfeName("bikes")
.setMfeStreamingData(true)
mfe { cfg ->
cfg.setMfeUrlResource("http://localhost:8081/bikes/stream")
cfg.setMfeName("bikes")
cfg.setMfeStreamingData(true)
}
The response must follow a simple contract:
- The first chunk is a wrapper containing one or more named
<slot>placeholders. - Each following chunk carries a
slot="..."attribute and is appended into the matching slot.
<!-- chunk 1: wrapper, flushed immediately -->
<main>
<h2>Bikes</h2>
<div class="grid"><slot name="bike"></slot></div>
</main>
<!-- chunks 2..n: one per item, flushed as soon as each is ready -->
<div slot="bike" class="card">Bike 1</div>
<div slot="bike" class="card">Bike 2</div>
On the server side, write the wrapper and flush it. Afterwards, write and flush each item.
For example, use Spring's StreamingResponseBody or HtmlFlow's streaming views
An example can be see in the micro-frontends streaming example
Security
Since the micro-frontends are based on HTML, they need to be safely created in the DOM. The engine sanitizes fragment HTML before inserting it using the HTML Sanitizer API. The full list of allowed elements and attributes can be seen here.
Script integrity - Experimental
Given the decentralization nature of micro-frontends architecture, the attack surface is increased.
The micro-frontends are composed in the client-side, increasing
the risk of malicious code injection and potentially compromising other micro-frontends with lateral movement.
To mitigate this risk, the engine supports Subresource Integrity for the fragment scripts.
When fragment scripts come from another origin, it is possible to set the SRI hash for the script using the setMfeScriptIntegrity method.
cfg.setMfeScriptUrl("https://cdn.example.com/mfe-bikes.js")
cfg.setMfeScriptIntegrity("sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC");
<script
type="module"
src="https://cdn.example.com/mfe-bikes.js"
integrity="sha384-oqVu…"
crossorigin="anonymous"
></script>
If the hash script does not match the provided hash, the browser will refuse to execute it.
HtmlFlow-Microfrontend-Examples is a runnable webshop with a shell and bikes, cart, and order services. It demonstrates events, reloading, shared theming, and streaming.
For content that arrives asynchronously (reactive streams, futures, or Kotlin suspend functions) and for streaming the HTML out progressively, see Advanced → Asynchronous Rendering and Streaming HTML.