Use any frontend you like. xF is API-first and headless.
React, Angular, Vue or a mobile app - it is your choice.
For those who miss the simplicity of the early web, we built something different. Our zero-build client combines metadata-driven UI generation with lightweight browser-side reactivity, allowing complete applications to be built using plain HTML, CSS and JavaScript. No build process. No bundler. No waiting for compilation. Just refresh and see the result.
# typical bundler workflow $ npm install && npm run build $ vite build --watch # xF zero-build client $ python -m http.server 8080 Serving HTTP on 0.0.0.0 port 8080 $
Raw HTML templates act as widget blueprints, and UI is a function of state: reactive bindings keep the DOM in sync with the data. The mapping below is an analogy to help you orient, not a claim of equivalence.
Vue-style syntax, no compiler. xf.vue.js is a fork of Petite-Vue exported as the xfvue global. Directives such as v-model, :bind and @event are interpreted in the browser.
A two-tier design: a public tier anyone can browse, and an authenticated workspace that mounts once a session is live. Pick a file to see what it does.
Loads CSS libraries [CDN Bootstrap, Tailwind etc.], Icons, Typography and a customisable local assets/style.css. Then the three runtime scripts.
xf.vue.js, xf.utils.js and xf.core.js<div id="xf_root">runApp()The CSS, icons and fonts come from public CDNs by default. Self-host them if your network requires it.
The unauthenticated storefront. Visitors browse catalogs, docs and public pages freely, and stay on this tier until they sign in.
pages/<name>.htmlThink of Flutter's StatefulShellRoute. The scaffold stays put while workspace views swap inside it.
#xf_view_slot as the swap targetA dynamic form builder driven by a remote schema. It renders the record header actions (Save, Create, Refresh, Close), then iterates layout groups and attributes.
xy.xf_gx groups and xy.xf_attr attributesxzxf_r is 4 or higher (read-only)One router decides which tier you are in. Everything after that is fetch, compose, mount.
Loads the runtime, then boots with renewSession() followed by XFRouter.init()
Reads the hash, checks the session and picks a tier
shell/landing.html or pages/<name>.html
shell/app.html with the #xf_view_slot mount point
Fetches schema y and data z from /api/xf_get
components/qXXXX assembled automatically, or a pages/cp_*.html custom override
Reactive bindings attach and the screen is live
None of these needs a compiler. All of them are visible in View Source.
Files are served as raw assets. No watch process, no transpilation, no waiting.
Layouts and field mappings come from the backend. Schema y and values z arrive per entity from /api/xf_get.
Reactive scopes and DOM bindings: v-model, :bind, @event, v-if, v-for and v-show.
Controllers and pages are fetched and evaluated on demand, and a screen's controls load in parallel. SheetJS and FileSaver start loading with the grid and are awaited only on export.
Simple inputs are pure templates. Interactive ones keep logic under window.F.<qCode>, so nothing collides.
Entity routes are gated behind a JWT. A pendingRoute survives the login flow and replays automatically.
TemplateLoader memoizes fragments. It ships with devMode = true (always fetch fresh), so set it to false and bump XF_BUILD per deploy in production.
The zero-build client does one job: render metadata-defined business screens with the fewest moving parts. That shapes what it does well, and what it leaves to other tools.
The advantage of removing the toolchain.
node_modules, lockfile, bundler or transpiler upgrades. The file you edit is the file the browser runs.Different tools for different jobs, and all of them can use the xF API.
Worth knowing before you deploy.
eval, so a strict Content-Security-Policy has to allow that.localStorage, as in many SPAs. Apply the same XSS hygiene.TemplateLoader.devMode defaults to true, which disables caching. Turn it off and bump XF_BUILD for production.The backend returns two payloads per entity. A string substitution step turns a generic template into a bound control.
Field values such as xz.customer_name, plus record flags: xf_id, xf_entity, xf_entityhdr, xf_r (read-only level) and xf_xu (custom post URL).
Labels xy.{{ATTR}}.h, layout classes .c, component code .q and group id .g. The group list xf_gx carries col, card and body classes, and xf_attr lists the attributes. Controls get parameters such as p1, p2, t and x.
XFEngine.composeHTML replaces every {{ATTR}} token before mounting. For attribute first_name, the control below binds to xz.first_name and xy.first_name.
<!-- Input template --> <div v-bind:class="xy.{{ATTR}}.c" class="component-container d-flex align-items-end"> <div class="w-100"> <label :for="'inp_{{ATTR}}'" class="form-label">{{ xy.{{ATTR}}.h }}</label> <input :id="'inp_{{ATTR}}'" type="text" class="form-control" v-model="xz.{{ATTR}}"> </div> </div>
Interactive components register under window.F.<qCode>. Vendor libraries load lazily and are awaited only when used, for example on an export click.
(function () { window.F = window.F || {}; // Vendors load lazily via XF.loadScript, resolved once and reused const VENDOR_BASE = `${$tpl()}/components/q1500/vendor/`; const vendorReady = XF.loadScript(VENDOR_BASE + 'xlsx.full.min.js') .then(() => XF.loadScript(VENDOR_BASE + 'filesaver.133.js')); window.F.q1500 = { sortTable: function (dT, c, asc) { /* ... */ }, saveToExcel: async function (data, filename = "export.xlsx") { if (!XFUtils.arrOk(data)) return; await vendorReady; // only waits when the user actually exports const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); XLSX.writeFile(wb, filename); }, redirect: function (path, id, params) { $init(path, id); } }; })();
The count of controls is not the point. What matters is that a control is a folder you copy into the deployment, and nothing sits between it and the browser.
Controls live in an open repository as snippets anyone can create, edit or fork. Add the folders your app uses, and leave the rest.
Signature pads, calendars, kanban boards and charts can become controls. Check the licence, keep the credit, and put the library in the control's vendor/ folder.
There is no component SDK, registry or build plugin to adopt. A control is an HTML template, an optional script and optional vendor files.
components/q9100/ ├── q9100.html // template: {{ATTR}} bound to xz and xy, like any control ├── q9100.js // registers window.F.q9100 and lazy-loads the library └── vendor/ // the third-party library, with its licence file // q9100.js, same pattern as the bundled q1500 grid (function () { window.F = window.F || {}; const libReady = XF.loadScript(`${$tpl()}/components/q9100/vendor/widget.min.js`); window.F.q9100 = { init: async function (el, xz, attrKey) { await libReady; // create the widget on el, and write its value to xz[attrKey] } }; })();
Point an attribute's .q metadata at the new code and the engine fetches the template and script on demand. To publish the control to the metadata store, add it to the list used by $syncControlTypes().
Any entity can opt out of the generic grid. XFEngine.composeHTML picks the template like this:
If the entity's schema entry has a page (or customPage) value, pages/<page>.html is fetched and its .js loaded first.
A custom page without the injection marker is used as written. Any F.qNNNN it references is detected and preloaded in parallel.
No page value, or the custom page fails to load: shell/entity.html renders the metadata grid.
A custom page can keep the <!-- SECTIONS AND COMPONENTS INJECTED HERE --> comment to get the generated grid inside its own layout. A missing .js file is skipped silently.
. ├── index.html # entry document and runtime bootstrap ├── assets/ │ ├── style.css # CSS overrides and design tokens │ ├── favicon.ico │ └── *.png / *.svg # branding and illustrations ├── shell/ │ ├── xf.vue.js # Petite-Vue fork (exports xfvue) │ ├── xf.utils.js # array, time and file helpers │ ├── xf.core.js # router, auth, loaders, state machine │ ├── app.html # authenticated shell frame │ ├── entity.html # generic entity view template │ └── landing.html # public storefront and sign-in ├── components/ # one folder per control you deploy: qXXXX.html [+ .js] │ ├── q1000/ q1100/ ... # example: only what the app uses │ ├── q1320/ # FK type-ahead picker │ └── q1500/ # data grid, with lazy vendor/ └── pages/ ├── cp_vehicle.html/.js # custom entity override └── custompage.html # static public page
These come from the hello-world starter, and are samples rather than a catalogue. The wider set lives in a GitHub repository of snippets that anyone can create, edit or fork.
| Code | Starter bundle control |
|---|---|
q1000 | Action button (posts a named action) |
q1010 | Toggle switch |
q1100 | Text input |
q1101 / q1105 / q1106 | Decimal, currency, integer |
q1120 | Textarea |
q1130 | Colour picker |
q1155 / q1158 | Date, time |
q1303 / q1318 | Select, lookup select |
q1320 | FK search picker (debounced) |
q1500 | Data grid: sort, row select, Excel export, row links |
$syncControlTypes() pushes each control's template and script to the metadata store through /api/tools/metadata/save-controltype.
Hash patterns keep everything static-hostable. Try an authenticated route while signed out and xF remembers where you were headed.
| Route | What happens | Auth |
|---|---|---|
#page/landing, empty hash or #login | Signed out: the sign-in landing page. Signed in: the same page, inside the shell. | No |
#page/<name> | Fetches pages/<name>.html and its optional .js. Standalone when signed out, inside #xf_view_slot when signed in. | No |
#<entity>/<id> | Mounts shell/app.html if needed, then XFEngine.loadEntity() builds the layout. An omitted id means -1, a new record. | Yes |
#logout | Clears the local session and returns to #page/landing. | No |
Login: XFAuth.login lowercases the username, hashes it with the password using a bundled SHA-256 and posts a time-stamped hash, not the raw password, to /api/xfsignin. The session lives in localStorage as xf_jwt, xf_user, xf_jwt_exp (last renewal time) and xf_appsettings.
Restore: at boot, renewSession() validates via /api/xfrenewsignin, and the entity list loads from /api/resources/metadata/schema.
Refresh: after any authenticated _get or _post, the session is silently renewed if five minutes have passed since the last renewal.
Forced logout: a 401 or #logout clears the session and returns to landing. If you were on an entity route, pendingRoute replays it after the next sign-in.
The header search button opens an entity picker. Pick an entity to search its records (300 ms debounce, /api/resources/query/table), or open an aggregate entity directly.
Records you close are remembered as fading dots in the header, up to nine, for one-click return.
window.xflush() bumps the cache-bust version, clears template, page and component caches, reloads the schema and re-renders the route.
$init(entity, id) // set the location hash: #entity/id $search(name, hdr) // open the command palette, optionally on one entity $getrequest(url, opts) // same-origin /api/ GET with toast handling $syncControlTypes() // publish control templates and scripts to the metadata store XFUtils // array, time and file helpers // available inside entity templates xpost(f) refresh() clone(src) delete_inst() close(route)
The runtime ships with Bootstrap 5 CSS. Swapping is a find-and-translate job across plain files: the shell templates, the control templates and the layout classes your metadata emits. No tooling is involved, and the xfvue directives stay identical.
In index.html, remove the Bootstrap stylesheet and add the Tailwind Play CDN script.
Remove<link href=".../bootstrap.min.css"
rel="stylesheet">
Add<script src="https://cdn.tailwindcss.com">
</script>
Tailwind documents its Play CDN as a development tool. For production you would normally generate a static stylesheet, which is where a build step comes back in.
Translate utility classes in app.html, landing.html and entity.html.
d-flex flex-column vh-100 → flex flex-col h-screenjustify-content-between align-items-center → justify-between items-centerbtn btn-primary rounded-pill → px-4 py-2 bg-blue-600 text-white rounded-fully payload)XFEngine builds grids from classes the backend emits, so change them at the source.
group.col: col-md-6 → w-full md:w-1/2 p-2group.card: card shadow-sm border-0 → bg-white shadow rounded-lg border border-gray-100xy.{{ATTR}}.c: col-md-4 pb-4 → w-full md:w-1/3 pb-4Translate the classes inside /components/. Toggle the switch above to compare q1100.html.
Bootstrap component<label class="form-label">{{ xy.{{ATTR}}.h }}</label>
<input type="text" class="form-control"
v-model="xz.{{ATTR}}">
Tailwind equivalent<label class="block text-sm font-medium
text-gray-700 mb-1">...</label>
<input type="text" class="w-full px-3 py-2 border
border-gray-300 rounded-md shadow-sm text-sm"
v-model="xz.{{ATTR}}">
Use whichever is already installed, then open http://localhost:8080. The landing page renders on its own. Sign-in, entities and search need an xF backend, or a proxy to one, answering /api/....
python -m http.server 8080
npx serve .
php -S localhost:8080
Run window.xflush() in the console to clear caches, reload the schema and re-render the route.
Explore the platform, or go straight to the Developer Guide and run your first entity.