Skip to main content

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, .l in Kotlin) emits the element's end tag and returns its parent, stepping back out one level.
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>

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 the Appendable immediately, as you chain the builders. Best for a document you render once.
  • HtmlFlow.view(template) → HtmlView<M> (lazy, reusable). The template, an HtmlTemplate lambda, is defined once and rendered many times, each time with a different model M.
// 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);
}
Why a view is faster

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 an HtmlView; receives both the parent element and the model. In Kotlin this is .dyn { model -> ... }.
  • .of(element -> ...): used in an eager HtmlDoc; receives only the element (the data is captured directly from the surrounding scope).
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"));

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.

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()).__();
})

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:

// 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))))
.__().__().__());

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.

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);
Views are immutable

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.

// 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
);
}
Call dynamic fragments inside a dynamic block

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.

// 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").__());

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​

  1. Build the shell view with mfeEnabled(true).
  2. Add the HtmlFlow MFE engine in <head> element of the page.
  3. Declare the micro-frontends in the view with mfe() method.
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 engine fetches fragments and stylesheets from the browser, therefore services on another origin must allow CORS requests from the shell.

warning

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:

OptionPurpose
setMfeUrlResource(url) requiredURL that represents the location of HTML fragment.
setMfeName(name) requiredUnique 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 memberDescription
rootThe 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.
tip

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.

.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"))
tip

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.

  • setMfeStylingUrl applies a stylesheet to this fragment only.
  • setMfeSharedStylingUrl applies 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.
.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"))
tip

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.

.mfe(cfg -> cfg
.setMfeUrlResource("http://localhost:8081/bikes/stream")
.setMfeName("bikes")
.setMfeStreamingData(true)

The response must follow a simple contract:

  1. The first chunk is a wrapper containing one or more named <slot> placeholders.
  2. 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>
tip

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.

Full example

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.