Build a full app with the SuiRenderer
This walks through a minimal but complete single-page app: a static
index.html shell, an app.js that wires the SuiRenderer to the
SuiEventBus, and a Spring Boot controller that returns UiPage JSON.
The shell is static — it never changes. All screens come from the server
as UiPage trees; the renderer paints them and the event bus handles clicks,
form submits, navigation and history.
1. The backend — return UiPage JSON
Any endpoint that returns the UiPage shape works — the SPA client only ever
sees JSON, so the backend language is your choice:
- Java / Spring Boot
- Node.js
@GetMapping(path = "/admin/products", produces = "application/json")
public UiPage list(@RequestParam(defaultValue = "") String q) {
var table = UiTable.of("products-table", "Products")
.column(UiColumn.text("sku", "SKU"))
.column(UiColumn.text("name", "Name"))
.rowAction(UiAction.danger("delete", "Delete")
.confirm("Delete this product?")
.dispatch("DELETE", "/admin/products/{id}"));
productService.findAll(q).forEach(p -> table.row(Map.of(
"id", p.getId().toString(), "sku", p.getSku(), "name", p.getName())));
return UiPage.of("/admin/products", table);
}
// Express: the same UiPage tree as JSON — no semantic-ui code on the server.
app.get("/admin/products", async (req, res) => {
const rows = await db.findProducts(req.query.q);
res.json({
type: "page",
navigate: "/admin/products",
node: {
type: "table", id: "products-table", title: "Products",
columns: [
{ type: "column", id: "c-sku", label: "SKU", dataKey: "sku" },
{ type: "column", id: "c-name", label: "Name", dataKey: "name" }
],
rows: rows.map(r => ({ type: "row", id: r.id, data: r })),
rowActions: [
{ type: "action", id: "delete", label: "Delete", style: "DANGER",
confirm: "Delete this product?",
onClick: { url: "/admin/products/{id}", method: "DELETE" } }
]
}
});
});
The UiPage's navigate value (/admin/products) is the URL the event bus
uses for history and deep-linking. (A Node backend can serve the SPA path or
render the page itself — the renderer is an npm package; see the
Node.js quickstart.)
2. The shell — index.html
A plain HTML file. It loads the stylesheet, provides one host element, and loads the app entry script. Nothing app-specific lives here.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My admin</title>
<link rel="stylesheet" href="/sui/sui.css">
</head>
<body>
<main id="app"></main>
<script type="module" src="/app.js"></script>
</body>
</html>
/sui/sui.css (and the renderer bundle) are served by the
mc-semantic-ui-core auto-configuration — you don't ship them yourself.
If your index.html is not hosted by a Spring Boot app with
mc-semantic-ui-core on the classpath, load the runtime from the public
docs site instead — no build step, no npm:
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/mindconnect-ai/mc-docs@sui-v0.1.0/sui/sui.css">
import { SuiRenderer, installDefaultHandlers }
from "https://cdn.jsdelivr.net/gh/mindconnect-ai/mc-docs@sui-v0.1.0/sui/renderer.js";
import { SuiEventBus }
from "https://cdn.jsdelivr.net/gh/mindconnect-ai/mc-docs@sui-v0.1.0/sui/eventbus.js";
Pinned tags are immutable; https://mindconnect-ai.github.io/mc-docs/sui/…
always tracks the latest main. URL shapes, versioning and a full example:
CDN assets for HTML clients.
3. The entry script — app.js
Create the renderer bound to the host element, attach the event bus, and load the first screen:
import { SuiRenderer, installDefaultHandlers } from "/sui/renderer.js";
import { SuiEventBus } from "/sui/eventbus.js";
const host = document.getElementById("app");
// Renderer paints UiNode trees into the host element.
const renderer = installDefaultHandlers(new SuiRenderer(host));
// Event bus wires clicks, form submits, navigation and browser history.
const bus = new SuiEventBus(renderer, host);
// Boot from the URL in the address bar so deep links survive a reload, and
// wire the popstate listener for back/forward. start() = navigate + history.
const path = window.location.pathname + window.location.search;
bus.start(path === "/" ? "/admin/products" : path);
That's the whole client. From here on:
- A click on a
data-triggerelement (button, link, row action) is caught by the bus, which fetches the newUiPage(orUiPatch) and applies it. - A
<form>submit is intercepted and dispatched with the right verb. bus.navigate(href)updates the URL and history; the back button works.
Bookmarkable deep links (SPA + JSON on one path)
A subtlety appears once your controller serves UiPage JSON and users
bookmark deep links. Both share one URL — e.g. /admin/products/42:
- The browser loading that URL must get the SPA shell (
index.html), so the bus can boot and fetch the page. - The bus fetching that same URL must get the JSON
UiPage.
The bus already distinguishes itself: every fetch it makes carries
Accept: application/json. A direct browser navigation carries
Accept: text/html. So the rule is simply: HTML wants the shell, JSON wants
the data.
Letting Spring decide this via produces does not work reliably — a
specific controller path (/{id}) wins over a generic SPA wildcard before
content negotiation runs, so the browser gets raw JSON. Intercept earlier, in a
servlet filter, and forward browser navigations to the shell:
@Component
@Order(0)
public class SpaForwardingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
if (isBrowserNavigation(req)) {
req.getRequestDispatcher("/index.html").forward(req, res);
return;
}
chain.doFilter(req, res);
}
private boolean isBrowserNavigation(HttpServletRequest req) {
if (!"GET".equals(req.getMethod())) return false;
String path = req.getRequestURI();
if (!path.equals("/admin") && !path.startsWith("/admin/")) return false;
// The bus fetches with Accept: application/json — let those through to
// the controller. Only HTML navigations get the shell.
String accept = req.getHeader(HttpHeaders.ACCEPT);
return accept != null && accept.contains("text/html");
}
}
This keeps one clean, bookmarkable path per screen (/admin/products/42, no
/api prefix, no client-side URL rewriting): the filter hands browsers the
shell, and the bus's JSON fetches flow straight to your @RestController.
Static assets (/sui/*, your CSS) and POSTs are untouched.
4. Authenticated apps (BFF / OIDC)
If your backend uses the Spring Security BFF pattern (server-held session,
HttpOnly cookie, CSRF token), swap the fetcher so every request carries the
session cookie and CSRF header, and redirect to login on 401:
import { bffFetch, redirectToLogin } from "/sui/bff.js";
const bus = new SuiEventBus(renderer, host)
.setFetcher(bffFetch)
.setOnUnauthenticated(() => redirectToLogin("/oauth2/authorization/keycloak"));
No other change — the rest of the app is identical.
CSRF tokens
There is nothing to switch on. On every POST, PUT, PATCH and DELETE to
its own origin the bus sends the token the page carries, and on nothing else:
- Meta tags
<meta name="_csrf">and<meta name="_csrf_header">— a page rendered byUiPageHtmlMessageConvertergets them whenever Spring Security put a token on the request, and its plain POST forms get a hidden_csrffield. - Otherwise the
XSRF-TOKENcookie, as theX-XSRF-TOKENheader — what Spring'sCookieCsrfTokenRepository.withHttpOnlyFalse()sets for a static SPA shell.
A page with neither sends exactly the requests it always did, and a header your
own fetcher sets is left alone. bus.setCsrf({ cookieName, headerName })
renames the cookie or header, bus.setCsrf(false) turns it off, and withCsrf
wraps a fetch of your own the same way.
On the Spring side, a cookie token is only written once something reads it, and the raw cookie value is only accepted by the plain request handler — the default one expects a masked token. For a static shell:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));
// …plus a filter that calls csrfToken.getToken() on each request, so the
// cookie is there before the first POST.
Forms, structure & validation
Fields, multi-column / tabbed / grouped layout, and server-driven validation
have their own page: Forms & validation. In short — a
form submits every named control inside its <form> as one payload, so you can
lay fields out freely (UiForm.content, UiFieldGroup, UiSection tabs) and
still submit the whole thing; on an invalid submit, re-render the form as a
UiPatch with UiField.error(...) / UiForm.error(...) set.
Embed as an island instead
If you don't want a full SPA shell, you can drop a single <div> into any
existing page and mount one tree — see
Embed as a UI island.