The reactive UI engine
with zero build.

One ES module, no dependencies, no compiler. Your server renders the HTML; Aegis wakes up the parts that need to be alive: signals, islands, a real cache, forms and a router, all in the same file.

<script type="module">import { island } from 'https://aegisjs.com/aegis.js'
page.html — 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 './aegis.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>
0build steps, dependencies, config files
11 KBsignals + scope, gzip
21 KBislands, templates, lists, events (production)
1072tests green in Chrome and Firefox
MITone file you can read

Four rules, no surprises

The whole mental model fits on a card.

  1. ${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).
  2. Everything lives in a scope. Effects, listeners, timers and resources created inside island() / component() / mount() die with the component. Outside a scope you get E001.
  3. Use the ctx versions. on, effect, interval, observe from the setup context are bound to the component's scope; the imported ones are not.
  4. Data never goes through innerHTML. Data goes through ${} in html`` (text nodes, never parsed). Server HTML goes through swap() / adopt().

What you get in the one file

Each part is independent. Import what you use; a bundler tree-shakes the rest, and build.mjs does the same without one.

Signals

TC39-aligned signals, computeds and effects with glitch-free propagation, scopes that clean up after themselves, deep reactive() objects.

Reactive core →

Islands

<div data-aegis="chart"> on a server page comes alive with typed props, lazy loading on visibility, JSON props, morph-safe swaps.

Components →

Templates

html`` parsed once by a real tokenizer, CSP-safe, with @click, .prop, ?bool, bind:value and keyed list().

DOM →

Data & cache

One resource() for SWR, offline and streaming; mutations with optimistic patch logs; ETag, persistence, cross-tab sync, a circuit breaker.

Data →

Forms

wireForm() upgrades a plain <form>: Constraint Validation, schemas, async rules, 422 mapping, wizards, drafts, accessible errors.

Forms →

Router & a11y

Navigation API router with loaders, guards before the URL commits, View Transitions; focus traps, roving tabindex, live regions.

Routing →

The canonical dozen

Aegis exports a lot. You need about twelve names; the table says which.

Job Use Not
Component on a server page island(name, Component, { types }) register() (low-level; keep for { load })
Component anywhere else mount(el, Component) component()
Custom element element(tag, Component, { props }) defineElement()
State signal, computed, effect, reactive store()
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']) cachedResource(), offlineResource()
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()

Security is compiled in, not bolted on

Attribute sinks are typed from the template's static prefix: href=${v} can never become javascript:, srcdoc and on* need trusted(), Trusted Types and Sanitizer API are used when present, islands have trust zones, headers never leak to other origins.

Read the S-codes

Numbers

Median of 5 runs, headless Chrome, milliseconds. Run bench.html yourself.

DOM (1,000 rows) ms Core ms
create 17.1 (12.7 with delegateEvents) deep chain 100 computeds × 1,000 writes 9.4
replace all 14.3 fan-out 1,000 effects × 100 writes 10.4
update every 10th 0.5 diamond × 100,000 30.5
select row 0.1 batch 1,000 signals × 100 2.5
swap rows (2 insertBefore calls) 0.4 html`` × 1,000, static 3.5
remove row 0.1 html`` × 1,000, 2 signal bindings 4.4
append 1,000 13.4 reactive() filter 100k rows in an effect 30
create 10,000 146.6 (131.5 with delegateEvents) reactive() for..of 100k rows 20
clear 12.5 reactive() wrap 100k rows, heap 17 MB

Works with what you have

No npm required. Django, Rails, Laravel, Go, PHP, htmx, Turbo, jQuery pages: islands inserted by anyone come alive with hydrate(root, { watch: true }).

<script type="importmap">{ "imports": { "aegis": "https://aegisjs.com/aegis.min.js" } }</script>
<script type="module">
import { island, resource } from 'aegis';

island('users', ({ props, html, when, list }) => {
    const users = resource(props.url, { cache: true, staleTime: 30_000 });
    return when(users, {
        loading: () => html`<p class="skeleton">Loading…</p>`,
        error: (e, retry) => html`<p>${e.message} <button @click=${retry}>Retry</button></p>`,
        data: (rows, rowsSignal) => html`<ul>${list(rowsSignal, (u) => html`<li>${u.name}</li>`, { key: 'id' })}</ul>`,
    });
});
</script>
Quick startSee the examplesllms.txt for your AI assistant