Beyond the api/, lib/, spi/, and classpath/ trees covered in Element Anatomy: A Technical Deep Dive and Packaging an Element with Maven, an Element can ship two additional content trees inside its .elm archive (or exploded directory): static/ and ui/. Both are served over HTTP automatically when the Element is deployed, but they serve different purposes:
static/– arbitrary static assets bundled with the Element (a small SPA, JSON configuration, icons, downloadable files).ui/– dashboard UI plugin bundles, which extend the Elements admin console with custom pages.
This page documents how both trees get loaded and served at runtime. For how they’re populated during a Maven build, see Packaging an Element with Maven.
Loading Static Content #
When an Element is deployed, the loader reads its static/ and ui/ directories (if present) into an in-memory file listing before anything is served. This works identically whether the Element is deployed as an exploded directory on the Element path or as a packaged .elm archive; the archive is simply opened as a zip filesystem and walked the same way as a real directory. An Element with neither directory simply serves nothing under either mount, which is why both trees are optional.
URL Routing #
Each content tree is mounted at its own URL prefix, computed independently at deploy time:
| Content tree | Default URL pattern | Override attribute |
|---|---|---|
static/ | /app/static/{prefix}/... | dev.getelements.Element.static.uri |
ui/ | /app/ui/{prefix}/... | dev.getelements.Element.ui.uri |
{prefix} comes from the Element’s dev.getelements.elements.app.serve.prefix attribute, and falls back to the Element’s own @ElementDefinition name if that attribute isn’t set. Either mount point can be overridden per-Element with the corresponding attribute above, in which case the override value is used verbatim as the context path instead of the computed /app/{static|ui}/{prefix} path.
Note
Before mounting a content tree, Elements checks the computed context path against its registry of reserved system paths (the REST API root, WebSocket root, and similar). If your override collides with a reserved path outright, deployment of that content tree is rejected; if it merely overlaps a catch-all route, the reserved system routes still take priority so they keep working.
Serving Behavior #
Once mounted, each tree is served by a small built-in static file servlet with the behavior you’d expect from a CDN-style file server:
- An index file is served for directory-style requests. The default is
index.html, and it can be overridden per content tree with an attribute of the formdev.getelements.{static|ui}.index(usestaticoruidepending on which tree you’re configuring). - Custom error pages can be configured per HTTP status code with an attribute of the form
dev.getelements.{static|ui}.error.<code>, e.g.dev.getelements.static.error.404. - Conditional requests (
ETag/If-None-Match,Last-Modified) and byte-range requests (Range/If-Range) are both supported, so large downloads and media files behave correctly with browsers and download managers. - MIME types and any per-file headers are resolved once, at Element load time, not per request.
The Dashboard UI Plugin Convention #
The ui/ tree is just static content, but the Elements admin dashboard imposes a specific convention on top of it so it can discover and load your plugin automatically. There is no special-cased server code for this; the dashboard finds your plugin purely by fetching well-known files over HTTP from the URLs above.
Lay out ui/ with one subdirectory per dashboard surface you want to extend:
ui/
superuser/
plugin.json
plugin.bundle.js
User/
plugin.json
plugin.bundle.js
Each subdirectory is served at /app/ui/{prefix}/{segment}/, where {segment} is superuser or User. The dashboard fetches plugin.json from each segment it finds and, if present, loads the referenced bundle. A minimal plugin.json:
{
"schema": "1",
"entries": [
{
"label": "Example Element",
"icon": "Package",
"bundlePath": "plugin.bundle.js",
"route": "example-Element"
}
]
}
| Field | Description |
|---|---|
schema | Manifest schema version. Currently "1". |
entries[].label | Text shown for this plugin in the dashboard sidebar. |
entries[].icon | name of a Lucide icon to display next to the label. |
entries[].bundlePath | Path to the plugin’s JavaScript bundle, relative to plugin.json. |
entries[].route | Route segment used to reach the plugin at /plugin/{route} in the dashboard. |
plugin.bundle.js must be a self-contained, immediately-invoked script (no module loader or bundler runtime of its own) that registers a component against the route named in plugin.json:
(function () {
var React = window.React;
function ExamplePluginPage() {
return React.createElement('div', null, 'Hello from my Element');
}
window.__elementsPlugins.register('example-Element', ExamplePluginPage);
})();
The bundle reuses the dashboard’s own window.React instance rather than bundling a copy of React itself, which keeps plugin bundles small and avoids duplicate-React errors. To discover plugins, the dashboard lists the Elements currently deployed, extracts each one’s /app/ui/{prefix}/ base path from its exposed URIs, fetches plugin.json for each known segment, and injects a <script> tag for the referenced bundle.
See Building the Example Element: A Complete Walkthrough for a full working example, including the TypeScript/React source the example project builds into plugin.bundle.js.

