Skip to content

Static bundle — serve the UI from a CDN, backend-optional

Status: ✅ Implemented (Vaadin and Redwood/VB renderers)

Mateu can export your declared screens to a static bundle — a folder of pre-rendered JSON plus the renderer assets — that any static host (Netlify, S3, GitHub Pages, a CDN) serves with no Mateu backend running. The renderer boots, reads the bundle, and paints each screen from it; live data still flows from external endpoints, and screens that need server logic keep working when a backend is present.

demo/demo-static-bundle is a minimal, self-contained example (two static @UI screens, one with a live @RestOptions select). Two ways to see the bundle:

Terminal window
cd demo/demo-static-bundle
mvn spring-boot:run # then GET http://localhost:8097/mateu/v3/bundle (runtime, no build)
mvn -Pbundle package # then serve target/mateu-bundle/ from any static host (no backend)

Two halves, both shipped:

  1. Build-time export — the mateu:bundle Maven goal renders every static @UI/@Route route’s initial load (the exact JSON the server returns for a page load) into a manifest.json, copies the renderer assets, and stamps a static index.html.
  2. Client bundle mode<mateu-ui bundleUrl="…"> fetches that manifest.json once at boot and answers route loads from it instead of calling the backend. It reuses the same render pipeline and client structure cache, so the screen looks identical to a server-rendered one.

Add the plugin to the app that declares your @UI classes and run mvn package (or the goal directly):

<plugin>
<groupId>io.mateu</groupId>
<artifactId>mateu-bundle-maven-plugin</artifactId>
<version><!-- the Mateu version you use --></version>
<configuration>
<!-- Optional: the base URL stamped into the page; "" = same-origin static host -->
<baseUrl></baseUrl>
<!-- Optional: where the renderer assets (_index.html + assets/) live; defaults to the
vaadin-lit resources on the classpath when they are an exploded directory -->
<assetsFrom>${project.basedir}/../../backend/shared/frontend/vaadin-lit/src/main/resources/static</assetsFrom>
</configuration>
<executions>
<execution><goals><goal>bundle</goal></goals></execution>
</executions>
</plugin>

Output lands in target/mateu-bundle/:

target/mateu-bundle/
├── index.html # boots <mateu-ui bundleUrl="/manifest.json">
├── manifest.json # one pre-rendered increment per static route
├── assets/ # the renderer bundle (mateu-vaadin.js, vendors, css)
└── _redirects # SPA fallback (/* → /index.html) for static hosts

Deploy that folder to any static host. The goal prints a rendered/total summary; a route it could not render (see the boundaries below) is logged and skipped — it stays backend-served.

Parameter (-Dmateu.bundle.*)DefaultMeaning
outputDirectorytarget/mateu-bundlewhere the bundle is written
baseUrl""stamped into <mateu-ui baseUrl> + the manifest URL
basePackagesinferred from the @UI classesapp packages to component-scan for the @Service/@Component beans your ViewModels inject
routesall discovered static routesoptional allowlist
skipParamRoutestruetrue skips :param routes; false bundles them as templates
assetsFromvaadin-lit resourcesdirectory holding _index.html + assets/
pageTitleMateu<title> of the static page
failOnEmptyfalsefail the build if zero routes rendered
failOnSkippedfalsefail the build if any route could not be bundled — turn it on once your bundled set is stable, so a route dropping out stops being a silent regression

The manifest carries a structureHash: a stable identity of what the bundle contains, independent of when it was built. generatedAt cannot answer “is this the same bundle?” — it differs on every build, and a stale bundle looks as fresh as any other.

That matters because a bundle is derivation frozen in time. Deployed to a CDN it can outlive the model it came from, and in a hybrid deploy a client can be answering loads from build N while posting actions to a backend at N+1, with nothing anywhere saying so. Comparing hashes is what makes the bundle a cache rather than a fork — same hash, same screens — and gives a deploy check something to assert.

The hash ignores entry order (route discovery walks beans and indexes, so the order is not stable between builds) and changes when a route stops being bundled, which is exactly the regression that otherwise leaves only a line in a build log.

Serving the bundle at runtime (no build step)

Section titled “Serving the bundle at runtime (no build step)”

You don’t have to run the Maven goal: a running Mateu app exposes the same bundle live at

GET /mateu/v3/bundle

which returns the identical manifest.json, rendered from the app’s real bean graph — so it has full fidelity (services/DB available, no skipped service-backed loads) and needs no build. Add ?params=true to also include :param route templates. Two uses:

  • Point a static shell at it — host a small index.html + the renderer assets on a CDN and set <mateu-ui bundleUrl="https://your-app/mateu/v3/bundle">. The shell fetches the bundle over CORS (the endpoint sends the header) and answers loads from it — instant first paint, backend still there for actions. (Same-origin bundleUrl="/mateu/v3/bundle" works too.)
  • Snapshot itcurl https://your-app/mateu/v3/bundle > manifest.json to produce the bundle in CI without the Maven goal.

The result is computed once and cached (structure is stable within a deployment). The endpoint ships in every server adapter — Spring MVC, Spring WebFlux, Micronaut, Quarkus and Helidon MP — and discovers routes from the live RouteResolver beans, so it works whether your @UI classes live in the app module or in a separate UI module.

Cross-origin note: the Spring adapters (MVC/WebFlux) send Access-Control-Allow-Origin via @CrossOrigin. On the other adapters the endpoint relies on the app’s own CORS configuration (micronaut.server.cors, quarkus.http.cors, Helidon’s CORS feature) — the same requirement as the main /mateu/v3/sync endpoint. Same-origin serving needs no CORS at all.

A parameterised route (/orders/:id) can’t be pre-rendered for one specific id, but if its structure is param-independent (a detail screen whose data is fetched client-side), it can be bundled once as a template:

  • Enable it: mvn -Pbundle package -Dmateu.bundle.skipParamRoutes=false, or hit the runtime endpoint with GET /mateu/v3/bundle?params=true.
  • The exporter renders the route once with a placeholder param and stores the entry with a regex (^orders/([^/]+)$) and the param names.
  • At runtime the client matches a concrete path (/orders/42) against the template, extracts the params and injects them into the screen’s state (state.id = "42"). Any ${state.id} in a client-side data URL (@RestOptions/@RestData) then resolves to the real value — so a per-id detail screen works served from a static host with no backend.
@UI("/item/:id")
@Title("Item ${state.id}")
public class Item {
private String id; // receives the real id from the path at runtime
@RestOptions(url = "https://api.example.com/posts?userId=${state.id}", valuePath = "id", labelPath = "title")
private String relatedPost; // options fetched client-side for id=42
}

A view whose load hard-fails on the placeholder (e.g. parses it as a number and reads a DB) is skipped, exactly like a static view that needs a live backend. See demo/demo-static-bundle (/item/:id).

  • Presentational and form screens — any declared @UI/@Route whose initial render is structural (fields, sections, tabs, layouts).
  • Live data via external endpoints@RestOptions/@RestListing/@RestData/@RestAction fetch directly from your REST APIs client-side, so a bundled screen shows real data with no Mateu backend (use proxy = true needs a backend; the direct mode is what works statically).
  • Actions — a button/toolbar/save (actionId ≠ "") posts to the server. Without one it degrades with a clear “request failed” message. Bundle mode is for viewing; mutations need a backend.

  • Parameterised routes (/orders/:id) — skipped by default; can be bundled as templates when the structure is param-independent (data fetched client-side). A view that loads its entity server-side by id still needs a backend.

  • Service-backed loads — a ViewModel whose initial load needs a live DB / a bean the build can’t construct is skipped at export time (logged) and stays backend-served.

  • Screens gated on identity — a route whose class, field or method declares @EyesOnly is skipped on purpose and stays backend-served. A bundle is one file served to everyone, and export runs headless: with no Authorization header the authorizer denies restricted content, so nothing leaks — it fails closed. What would leak instead is correctness: the baked variant is the denied one, and an authorised user hitting a static host has no server left to re-render the version they are entitled to. The skip reason names the member, so the build log says which one.

    @Audience is deliberately not treated this way: it is a UX projection, not access control, so with no audience selected the export renders the full, unprojected view — correct for everyone, merely not personalised.

A hybrid deploy is the sweet spot: ship the bundle for instant, backend-free first paint, and point baseUrl at a real backend so actions and unbundled/param routes still work — the client uses the bundle for the loads it has and falls through to the backend for everything else.

  • Vaadin and every renderer built on the shared libs/mateu client get bundle mode for free — set <mateu-ui bundleUrl="…"> (the mateu:bundle goal stamps it into the generated index.html).
  • Redwood/VB has its own transport (it shares no code with the web renderers), so bundle mode is ported there too: set the app constant mateuBundleUrl (empty = off) to a manifest.json URL or the runtime …/mateu/v3/bundle endpoint. Route loads are answered from the bundle; in a hybrid deploy the menu/shell still comes from the backend, and if the backend is absent the shell falls back to the bundled root route. Contract-tested in apps/redwood/poc/test.mjs.
  • The manifest carries only screen structure (never business data), so nothing stale is baked in; data is always fetched live.
  • The manifest also carries the mount’s route registry when the app declares one — a statically deployed mount has no server left to ask what a URL means, so the renderer resolves routes and their parameters from shipped data. Only the authored half travels: the annotation-derived half is route→class, and a class is what a bundle with no backend cannot use. Routes that exist only in routes.yaml are exported too, including those with no view model — a definition that declares no modelView renders as a bare layout through the ordinary sync path, so it is pre-rendered like any other route.