Your server renders the page. Aegis makes parts of it alive.
One ES module, no build step, no bundler required. Django, Laravel, Rails, Go, PHP: keep your templates, add data-aegis where the page has to react.
Live playground · Docs · Examples · API reference · llms.txt
<!-- rendered by Django / Rails / Laravel / Go / PHP … -->
<div data-aegis="counter" data-start="5">
<button>Clicked 5 times</button>
</div>
<script type="module">
import { island } from 'https://cdn.jsdelivr.net/npm/@aegisjs/engine@0.7.2/aegis.min.js';
island('counter', ({ props, signal, html }) => {
const count = signal(props.start);
return html`<button @click=${() => count.value++}>Clicked ${count} times</button>`;
}, { types: { start: Number } });
// no mount() call: every [data-aegis] on the page is hydrated after island()
</script>That is the whole setup. The server HTML is visible before JavaScript runs and indexed by search engines; the island replaces it with a live template and keeps working after any server-driven swap.
- Server HTML stays the source of truth. No Node SSR, no client tree that re-renders what the server already sent.
- A form works without JavaScript.
wireForm()only improves it: live validation, 422 errors on the right fields. - An island hydrates inside htmx, Turbo or jQuery pages. Markup inserted later wakes up too.
- 30 KB gzip for islands + templates, zero dependencies, one file you can read. Pin a version, add a hash, ship.
Start here: Getting started (a base.html for Django, Laravel, Rails and Go) · from Alpine · with htmx · when not to use Aegis.
You have a server that renders HTML and you want parts of the page to be alive: a search box, a form with validation, a table, a cart. The usual answer is a bundler, a component framework and a second rendering stack. Aegis is the other answer, and its neighbours are the other libraries that live inside server HTML.
| Aegis | Alpine | Petite-Vue | htmx | Stimulus | Lit | |
|---|---|---|---|---|---|---|
| Build step | none | none | none | none | none (or a bundler) | usually a bundler |
| Size, gzip | 30 KB islands + templates (7 KB signals, 93 KB everything) | 15 KB | 6 KB | 14 KB | 11 KB | 16 KB |
| Server HTML | adopted as is; swap() morphs fragments; islands survive swaps |
directives on the markup | directives on the markup | swapped as strings | controllers on the markup | replaced by shadow DOM |
| Reactivity | signals, computeds, effects; glitch-free, batched | proxy + directives | proxy + directives | none (server round-trip) | none (imperative) | properties → re-render |
| Templates | html``` in JavaScript, parsed once, CSP-safe; keyed list()` |
x-for / x-text in HTML |
v-for / {{ }} in HTML |
HTML from the server | targets in HTML | `html``` (lit-html) |
| Data | resource(): cache, revalidation, ETag, offline, optimistic patches |
none | none | requests per attribute | none | none |
| Forms | Constraint Validation, schemas, 422 mapping, wizards, drafts | none | none | server-side | none | none |
| Navigation | Navigation API router, boost() for server pages, View Transitions |
none | none | hx-boost |
Turbo | none |
| Dev warnings | 60+ codes: what / why / fix, source position | none | none | none | none | none |
The honest reading of the table: Alpine and Petite-Vue are smaller and fine for a dropdown; htmx is the right call when the server can render every state; Aegis is for the pages where you would otherwise reach for a component framework — a table with search and paging, a form with server errors, a cart, an admin screen — and want to keep the server templates.
Aegis is not a React replacement. If you have a single-page application, a React team and a component ecosystem you rely on, stay there. The comparison that matters for Aegis is “bundler + framework + data library on top of a server app” against “one file inside the server app”, and the same todo list is 18 lines in Aegis, 22 in React and 18 in Vue: see it side by side in the playground (tab “vs React / Vue”).
- A single-page application with client-side routing for everything. Aegis has a router, but its home is a server that renders pages; if the server only serves JSON, a SPA framework fits better.
- A team that lives in JSX and the React ecosystem. Storybook, React Native, thousands of components: those are not Aegis, and they are not coming.
- A dropdown or a tab strip on a static page. Alpine or Petite-Vue is smaller and enough.
- Every state rendered by the server. If htmx alone covers the page, add Aegis only for the islands that need client state.
- Browsers without ES modules. Aegis ships as an ES module; there is no ES5 build.
- Admin panels and dashboards on any backend — Django, Rails, Laravel, Yii, Go, PHP: keep server templates, add islands where the page has to react. The admin demo is a router, a table with search and paging, optimistic mutations, forms with server errors and a 50 000-line virtual log in one file.
- Progressive forms — a plain
<form>works without JavaScript;wireForm()adds live validation, async rules, server 422 errors on the right fields, wizards and drafts. - Search, filters, infinite lists —
resource()turns a query signal into requests that abort, dedupe, cache and revalidate;list()keeps rows keyed. - Content sites that stay server-first —
swap()andboost()navigate and patch server fragments with morph, View Transitions and prefetch on intent; islands survive. - Widgets in someone else's page — an island is one
data-aegisattribute;hydrate(root, { watch: true })wakes markup inserted by htmx, Turbo or jQuery. - Offline-capable tools —
resource(url, { offline: true })persists to IndexedDB and replays a mutation queue on reconnect.
Runnable versions of every example: the examples catalogue (24 patterns, each editable on the page), recipes/ in this repository, the playground and demo/admin.html.
${x.value}is a snapshot,${x}and${() => …}are live. A signal or a function in a template re-renders on change; a plain value renders once (dev warns with E019).- Everything lives in a scope. Effects, listeners, timers and resources created inside
island()/mount()die with the component. Outside a scope you get E001. - Use the
ctxversions.on,effect,interval,observefrom the setup context are bound to the component's scope; the imported ones are not. - Data never goes through
innerHTML. Data goes through${}inhtml``` (text nodes, never parsed). Server HTML goes throughswap()/adopt()`.
Aegis exports a lot (about 160 names, most of them small helpers). The API has three tiers:
- Core, ten names, enough for most pages:
signal,computed,effect,batch,html,island,mount,resource,wireForm,swap. - Extended: everything else — helpers for specific jobs (router, forms, cache, motion, accessibility). Same file, same guarantees; a bundler or
build.mjsleaves out what you do not import. - Removed in 0.7.1: the old aliases
clsMap,store,cachedResource,offlineResourceandcomponentare gone;cls(el, { … }),reactive(obj, { shallow: true }),resource(url, { cache: true }),resource(url, { offline: true })andmount()are the names. Nothing in the file is marked@deprecated.
| Job | Use | Not |
|---|---|---|
| Component on a server page | island(name, Component, { types }) |
register() (low-level; keep for { load }) |
| Component anywhere else | mount(el, Component) |
— |
| Custom element | element(tag, Component, { props }) |
defineElement() (low-level definition object; keep for reflection and formAssociated) |
| State | signal, computed, effect, reactive |
— |
| Template | html```, when, list, show` |
text/attr/cls/style (for adopting existing DOM) |
| Data | resource(url, { cache, offline, params, loader }), mutation, api, invalidate('/api/users*') / invalidate(['users']) |
— |
| Forms | wireForm(formEl) for server forms, form(defaults) for virtual ones |
— |
| Server HTML | swap, adopt, boost |
innerHTML with data |
| Router | router({ '/users/:id': { loader, component: Page } }, { outlet, hash }) — props = params + { data, query } |
handler + manual mount() |
Component is one contract everywhere: ({ props, html, signal, on, … }) => Node | api | void.
Every release is served from https://aegisjs.com/<version>/ and never changes; the import map carries the integrity hash, so the browser refuses a file that does not match:
<script type="importmap">
{
"imports": {
"aegis": "https://cdn.jsdelivr.net/npm/@aegisjs/engine@0.7.2/aegis.min.js",
"aegis/core": "https://cdn.jsdelivr.net/npm/@aegisjs/engine@0.7.2/aegis.core.min.js",
"aegis/test": "https://cdn.jsdelivr.net/npm/@aegisjs/engine@0.7.2/aegis-test.js"
},
"integrity": { "https://cdn.jsdelivr.net/npm/@aegisjs/engine@0.7.2/aegis.min.js": "sha384-33WGGKOW1cXvGQODmX6XMy0kRCb00Gkgy4P5vJ08J+mqES1wy9UbLCjKtqAbhvhU" }
}
</script>
<script type="module">
import { island, html } from 'aegis';
</script>The integrity key of an import map is enforced in Chrome 127+ and Safari 18+; an older browser ignores the key and loads the same file, still pinned by its URL. Nothing breaks either way, and the hash is checked wherever the browser knows how.
The block above pins the npm release on jsDelivr, which serves it the moment it is published; dist/importmap.html in this repository carries the same three files with modulepreload and hashes, regenerated by build.mjs on every version. The site mirrors each release at https://aegisjs.com/<version>/ once it is deployed, and https://aegisjs.com/aegis.js without a version is the latest release, for experiments only. The hashes of every file: dist/integrity.json and aegisjs.com/docs/installation.
One file is served, but you rarely ship all of it. Every number on this page is real; they measure different things:
| Number | What it is |
|---|---|
| 7 KB gzip | signal, computed, effect, batch, createScope after a production build: the reactive core alone |
| 30 KB gzip | the usual page: islands, html templates, lists, events and two-way binding, production build |
| 93 KB gzip | every export, production build (node build.mjs --from app.js strips the developer layer) |
| 108 KB gzip | aegis.min.js as served from the pinned URL: the same code plus the 60+ dev warnings, which only fire on localhost |
| 11 KB gzip | aegis.core.min.js, the signals + scope file for non-DOM code |
The pinned URL is the whole file, so a page that imports five names still downloads 108 KB. If that matters, build the subset once (below) or let a bundler tree-shake it; the per-subset numbers are in docs/distribution.md.
The production numbers assume the dev layer is dropped: build.mjs does it for you, a bundler needs define: { 'globalThis.AEGIS_PROD': 'true' } (esbuild, Vite, Rollup, webpack all spell it similarly). Without that define the same subsets weigh 11 KB and 38 KB instead of 7 and 30 — the difference is the 60+ dev warnings.
Copy aegis.js (or aegis.min.js + .map) into your static folder next to your templates — it is one file with no dependencies. aegis.core.js is the signals + scope subset for non-DOM code.
<script type="module">
import { signal, island, html } from './aegis.js';
</script>Three lines: the repo reads the import { … } from 'aegis' line of your app and writes one tree-shaken production module.
git clone https://github.com/bauratynov/aegis && cd aegis
node build.mjs --from ../app/static/js/admin.js --out ../app/static/js/aegis.js
# → one file with exactly the exports admin.js imports (a 14-export admin bundle is 22 KB gzip); unknown names fail here, not in the browserThe package is @aegisjs/engine — the scope matches the site, and the bare names are taken: aegis and aegis-engine belong to other projects, and aegisjs is refused by npm as too close to aegis-js (last published 2022).
npm i aegis@npm:@aegisjs/engine # keeps `from 'aegis'` working under a bundler
npm i @aegisjs/engine # or the real name, then import from '@aegisjs/engine'The exports map (., ./min, ./core, ./core/min, ./devtools, ./test) resolves the same subpaths either way. The pinned URL above stays the release channel for pages without a bundler.
The examples on the site import from 'aegis' (in this repository they import ./aegis.js, so that a clone runs with no import map at all). That name is not magic: it comes from the import map in your base template (the pinned block above). With a bundler it comes from node_modules instead — install under that name with npm i aegis@npm:@aegisjs/engine, or import '@aegisjs/engine' directly. Without either, import the URL directly:
import { island } from 'https://cdn.jsdelivr.net/npm/@aegisjs/engine@0.7.2/aegis.min.js';The full documentation is aegisjs.com: getting started with a base.html per backend, examples you can edit on the page, the playground and the API reference generated from aegis.d.ts. The same chapters live in this repository as Markdown:
| Chapter | Read |
|---|---|
| Quick start: an island, a todo list, data fetching | docs/quick-start.md |
| Features, chapter by chapter (reactive core, DOM, data, components, forms, routing, a11y, CSS, performance, debugging) | docs/features.md |
| Dev mode and the warning codes | docs/dev-mode.md, ERRORS.md |
| TypeScript, testing helpers, tree-shaking and custom builds | docs/typescript.md, docs/testing.md, docs/distribution.md |
| Verified by design: what the tests prove and what they do not | docs/verified-by-design.md |
| Browser support, background tabs, benchmarks | docs/browser-support.md, docs/background-tabs.md, docs/benchmarks.md |
| Philosophy and FAQ | docs/philosophy.md, docs/faq.md |
| Rules for AI assistants | llms.txt |
Runnable code: recipes/ (one self-contained HTML file per pattern), demo/admin.html (router, table, forms, a 50 000-line virtual log) and demo/bench.html.
Bug reports and pull requests are welcome; see CONTRIBUTING.md for the test suite, the conventions, a map of the sections of aegis.js and how a change gets in. Security issues go to the address in SECURITY.md, not the issue tracker.
@software{aegis2026,
author = {Atinov, Baurzhan},
title = {Aegis: a zero-build reactive UI engine for server-rendered pages},
year = {2026},
url = {https://github.com/bauratynov/aegis}
}MIT. Free for commercial use, attribution appreciated.
Created by Baurzhan Atinov (Kazakhstan) · aegisjs.com · baurzhanatinov@gmail.com
