Home  /  What makes it different  /  Zero-build frontend

No build. No bundler. Just the web.

Use any frontend you like. xF is API-first and headless.
React, Angular, Vue or a mobile app - it is your choice.

Edit a template, refresh the tab. Nothing to compile in between.

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.

~/xf-frontend
# 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
$ 
0build steps for the client
3runtime scripts to load
1 folderper control, copy only what you need
Open sourcereadable end to end
Mental model 4-file pipeline Lifecycle Pillars Trade-offs Mechanics Routing & auth Swap CSS framework Quick start
Mental model

A familiar shape, especially if you know Flutter.

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.

runApp(MaterialApp(...))→index.htmlHost bootstrapper
GoRouter / Navigator 2.0→XFRouterHash-based routing
Unauthenticated route→landing.htmlPublic portal
Scaffold(appBar, body)→app.htmlPersistent workspace shell
Dynamic ListView / Form builder→entity.htmlMetadata canvas
StatelessWidget / custom control→components/qXXXXAtomic controls
ChangeNotifier / setState()→xfvue reactive statexz (data) and xy (schema)

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.

The clean 4-file pipeline

Four files carry the whole runtime.

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.

The bootstrapper

index.html · never unmounts

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
  • Mounts <div id="xf_root">
  • Equivalent of Flutter's runApp()

The CSS, icons and fonts come from public CDNs by default. Self-host them if your network requires it.

Execution lifecycle

From URL hash to live screen.

One router decides which tier you are in. Everything after that is fetch, compose, mount.

index.html

Loads the runtime, then boots with renewSession() followed by XFRouter.init()

↓

XFRouter

Reads the hash, checks the session and picks a tier

↓
Unauthenticated

renderStandalone(name)

shell/landing.html or pages/<name>.html

Authenticated

mountShell()

shell/app.html with the #xf_view_slot mount point

↓

XFEngine

Fetches schema y and data z from /api/xf_get

↓

components/qXXXX assembled automatically, or a pages/cp_*.html custom override

↓

xfvue.createApp(...).mount()

Reactive bindings attach and the screen is live

Architecture pillars

Seven techniques that remove the toolchain.

None of these needs a compiler. All of them are visible in View Source.

01

Zero-build delivery

Files are served as raw assets. No watch process, no transpilation, no waiting.

02

Metadata-driven UI

Layouts and field mappings come from the backend. Schema y and values z arrive per entity from /api/xf_get.

03

xfvue reactivity

Reactive scopes and DOM bindings: v-model, :bind, @event, v-if, v-for and v-show.

04

Lazy by default

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.

05

Isolated scoping

Simple inputs are pure templates. Interactive ones keep logic under window.F.<qCode>, so nothing collides.

06

Session-aware hash routing

Entity routes are gated behind a JWT. A pendingRoute survives the login flow and replays automatically.

07

Template and asset caching

TemplateLoader memoizes fragments. It ships with devMode = true (always fetch fresh), so set it to false and bump XF_BUILD per deploy in production.

Where it fits

A different trade-off, stated plainly.

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.

What you get

The advantage of removing the toolchain.

  • Nothing to build or maintain. No node_modules, lockfile, bundler or transpiler upgrades. The file you edit is the file the browser runs.
  • UI changes without a frontend release. A new entity or field arrives as metadata, and its controls are fetched and mounted on demand.
  • Debuggable as written. Templates and controllers show up in devtools under their real file names.
  • A small, readable surface. Three runtime scripts and a handful of HTML templates, small enough to read end to end.
  • No proprietary layer. A control is plain HTML plus optional JavaScript. Any library on the internet can be adapted, licence permitting.
  • No lock-in. The client is optional. The API serves any frontend.

Where other stacks fit better

Different tools for different jobs, and all of them can use the xF API.

  • Highly bespoke, interaction-heavy products such as design tools, rich editors or canvas apps, where a large component and animation ecosystem pays for itself.
  • Static type checking. This client has no TypeScript or compile-time validation of templates.
  • Public, SEO-sensitive sites. Screens render in the browser from API data, not on the server.
  • Teams standardised on React or Angular for tooling, testing and hiring. Nothing stops them using that stack against xF.

Operational notes, straight from the source

Worth knowing before you deploy.

  • Component and page scripts are run with eval, so a strict Content-Security-Policy has to allow that.
  • The session JWT is kept in localStorage, as in many SPAs. Apply the same XSS hygiene.
  • Bootstrap CSS, Phosphor icons and fonts load from public CDNs by default. Self-host them where required.
  • TemplateLoader.devMode defaults to true, which disables caching. Turn it off and bump XF_BUILD for production.
Runtime mechanics

Templates in, reactive UI out.

The backend returns two payloads per entity. A string substitution step turns a generic template into a bound control.

z payload

The data

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

y payload

The structure

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.

Placeholder replacement

XFEngine.composeHTML replaces every {{ATTR}} token before mounting. For attribute first_name, the control below binds to xz.first_name and xy.first_name.

components/q1100/q1100.html
<!-- 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>

Component logic and vendor isolation

Interactive components register under window.F.<qCode>. Vendor libraries load lazily and are awaited only when used, for example on an export click.

components/q1500/q1500.js
(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); }
  };
})();

Controls are folders, not a catalogue

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.

01

Copy what you need

Controls live in an open repository as snippets anyone can create, edit or fork. Add the folders your app uses, and leave the rest.

02

Wrap any library

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.

03

No proprietary layer

There is no component SDK, registry or build plugin to adopt. A control is an HTML template, an optional script and optional vendor files.

Shape of a wrapped widget (q9100 is a placeholder code)
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().

Custom pages and page controllers

Any entity can opt out of the generic grid. XFEngine.composeHTML picks the template like this:

1st

Page named in metadata

If the entity's schema entry has a page (or customPage) value, pages/<page>.html is fetched and its .js loaded first.

2nd

Your markup, your controls

A custom page without the injection marker is used as written. Any F.qNNNN it references is detected and preloaded in parallel.

3rd

Generic fallback

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.

Directory structure

.
├── 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.

CodeStarter bundle control
q1000Action button (posts a named action)
q1010Toggle switch
q1100Text input
q1101 / q1105 / q1106Decimal, currency, integer
q1120Textarea
q1130Colour picker
q1155 / q1158Date, time
q1303 / q1318Select, lookup select
q1320FK search picker (debounced)
q1500Data 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.

Routing, auth and shell

A router that knows who you are.

Hash patterns keep everything static-hostable. Try an authenticated route while signed out and xF remembers where you were headed.

RouteWhat happensAuth
#page/landing, empty hash or #loginSigned 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
#logoutClears the local session and returns to #page/landing.No

Sign-in and session

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 and forced logout

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.

Global shell features

Command palette

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.

Recent history

Records you close are remembered as fading dots in the header, up to nine, for one-click return.

Instant iteration

window.xflush() bumps the cache-bust version, clears template, page and component caches, reloads the schema and re-renders the route.

Global primitives
$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)
Bring your own CSS

Swap Bootstrap for Tailwind without a build.

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.

Update global script and styles

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.

Refactor the core shell layouts

Translate utility classes in app.html, landing.html and entity.html.

  • d-flex flex-column vh-100 → flex flex-col h-screen
  • justify-content-between align-items-center → justify-between items-center
  • btn btn-primary rounded-pill → px-4 py-2 bg-blue-600 text-white rounded-full

Update the backend metadata (y 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-2
  • group.card: card shadow-sm border-0 → bg-white shadow rounded-lg border border-gray-100
  • xy.{{ATTR}}.c: col-md-4 pb-4 → w-full md:w-1/3 pb-4

Update component control templates

Translate 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}}">
Local development

The frontend itself needs only a static file server.

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 3
python -m http.server 8080
Node.js
npx serve .
PHP
php -S localhost:8080

Edited something?

Run window.xflush() in the console to clear caches, reload the schema and re-render the route.

Ready to ship without a build step?

Explore the platform, or go straight to the Developer Guide and run your first entity.