No virtual DOM, no diffing
Each template is parsed once and every instance is a cloneNode. Each dynamic hole owns a tiny effect, so changing one binding re-evaluates exactly one expression. Nothing is diffed, because there is nothing to compare.
Open source · v0.9 on npm · pre-release
AmoJS is a build-time compiler for fine-grained reactive UI, and a compiled
app costs 1.98 KB all-in, runtime included.
You write standard JavaScript — signals plus html`` tagged templates — and it runs
in the browser with no build step at all. The compiler is an optimizer, not a requirement.
No virtual DOM, no diffing, zero runtime dependencies.
npm install @amojs.dev/core
— plus @amojs.dev/router, @amojs.dev/compiler, @amojs.dev/cli and @amojs.dev/vite.
How it works
There is no compiler magic to learn and nothing to un-learn. The source on the left is a real file that runs unchanged in a browser through an import map. The output on the right is the actual emitted code — hoisted template, positional walks, one tiny effect per dynamic hole. It is the code a careful developer would have written by hand.
import { signal, computed, html, mount } from '@amojs.dev/core';
function Counter() {
const count = signal(0);
const double = computed(() => count.value * 2);
return html`<button onclick=${() => count.value++}>${count} × 2 = ${double}</button>`;
}
mount(Counter, document.body);
amo build real compiler outputimport { signal, computed, mount } from '@amojs.dev/core/runtime';
import { tpl as _$t, bindChild as _$child,
bindEvent as _$event } from '@amojs.dev/core/compiled';
const _t0 = _$t("<button><!----> × 2 = <!----></button>",
[[0,0],[0,2]]);
function Counter() {
const count = signal(0);
const double = computed(() => count.value * 2);
return (() => {
const _r = _t0().firstChild;
const _n0 = _r.firstChild;
const _n1 = _r.childNodes[2];
_$child(_n0, count);
_$child(_n1, double);
_$event(_r, "click", () => count.value++);
return _r;
})();
}
mount(Counter, document.body);
Notice what is not there: no component registry, no lifecycle object, no virtual nodes,
no diff. When count changes, exactly one expression re-evaluates and exactly one
text node is written. And because the compiler saw that html`` is no longer used,
the template parser is never even loaded.
Why AmoJS
Every framework asks you to bet your codebase on its future. AmoJS is designed so that bet is reversible — and so that the day-one experience needs no toolchain at all.
Each template is parsed once and every instance is a cloneNode. Each dynamic hole owns a tiny effect, so changing one binding re-evaluates exactly one expression. Nothing is diffed, because there is nothing to compare.
Source is plain .js with signals and tagged templates — load it over raw ESM and an import map and it works. Prototype in a single HTML file, add the compiler later when you want the bytes back.
amo eject — delete the frameworkEject emits readable vanilla JavaScript and rewrites every import to a relative path. The eject test executes the output and asserts zero bare specifiers, so module resolution never touches node_modules. Removing AmoJS provably changes nothing.
Every golden test runs the same fixture twice — once raw, once compiled — and asserts identical behavior. The uncompiled runtime is the source of truth, so the compiler can only make things smaller and faster, never different.
Your file is already valid JavaScript, so hover, go-to-definition, rename and type-checking work today — no language server, no virtual TypeScript, no editor plugin required to be productive.
Nothing ships to the browser but AmoJS itself. Compiler-side tooling runs on Node and never reaches your bundle. Size budgets are enforced in CI, so a regression fails the build like a broken test.
Measurements
Every figure below is a gate in continuous integration, not a claim in a README. If one regresses, the build goes red.
| Gate | Measured | Budget |
|---|---|---|
| Compiled counter app, all-in | 1,982 B | ≤ 2,048 B |
| Framework minus template parser, bundled | 2,520 B | ≤ 2,560 B |
| No-build app, parser included | 4,020 B | ≤ 4,096 B |
| Identity benchmark — compiled ÷ hand-written vanilla | 1.087× | ≤ 1.10× |
| DOM mutations vs a hand-written app | exactly equal | parity gate |
| List moves — swap / reverse / rotate | 2 / n−1 / 1 | LIS-minimal |
Measured on core 0.6.x. “All-in” means bundled, minified and gzipped with the runtime included — the way frameworks quote size.
Lit shares the technique: html`` tagged templates, zero dependencies, no build
required, one cached template cloned per instance. What differs is the update model — so that
is what these numbers isolate. Lit is measured in its smallest configuration
(bare html + render, no LitElement, no decorators, no shadow DOM).
| Scenario | AmoJS | Lit 3.3 |
|---|---|---|
| 1 of 5 bindings changes → expressions evaluated | 1 | 5 |
| Churn — the same values written again | 0 | 5 |
| All 5 change — a genuine tie | 5 | 5 |
| Shipped bytes, min+gzip | 2,336 B | 6,890 B |
The same app: a card with five bindings, an event listener and a keyed list. DOM mutations tie in every row — Lit does no wasted DOM work either. The difference is how many expressions must run to discover that.
What these numbers do not measure: ecosystem, server-side rendering, tooling maturity, browser-vendor backing and years of production hardening — all of which Lit, React, Vue and Svelte have and AmoJS does not. Being technically distinct is not the same as being a safe default.
Surface area
Three concepts, and that is the framework. There is deliberately no component API and no
lifecycle object: a component is a plain function returning a DOM node, props are its
arguments, composition is an ESM import, and teardown belongs to the ownership tree.
A template hole is exactly one of three things — a constant written once, a signal bound reactively, or a function wrapped in an effect. The same rule holds compiled and uncompiled, which is why there is no “this works in dev but not in prod” class of bug.
Routing · @amojs.dev/router
The router is built on the Navigation API (Baseline since January 2026) —
the platform's own routing primitive, which every older router still hand-builds around the
History API. One idea carries the whole design: a page's load() settles
before the page renders, so data is a resolved value, not a
resource. No page ever sees a loading or error state — those live in one place,
the router options.
import { html, mount } from '@amojs.dev/core';
import { router } from '@amojs.dev/router';
const app = router({
'/': () => import('./pages/home.js'),
'/users': () => import('./pages/users.js'),
'/users/:id': () => import('./pages/user.js'),
'*': () => import('./pages/404.js'),
}, {
pending: () => html`<p>loading…</p>`, // once, for every page
error: (err, retry) =>
html`<p>${err.message} <button onclick=${retry}>retry</button></p>`,
});
mount(app, document.getElementById('app'));
// pages/user.js
export async function load({ params, signal }) {
// the navigation's own AbortSignal — an abandoned
// page's fetch cancels itself, zero router code
return (await fetch(`/api/users/${params.id}`, { signal })).json();
}
export const title = (data) => data.name;
export default ({ data, params }) =>
html`<h1>${data.name}</h1>`; // never a promise, never a spinner
The platform does what other routers hand-build: plain <a href> just works —
no <Link> component, no click listener, no target="_blank" edge
cases; stale responses abort themselves; scroll restoration on back/forward is free; a plain
<form method="post"> is handed to the page's action() with
native validation running first. A browser without the Navigation API gets full page loads —
the app is slower, not broken. The router adds ~1.3 KB min+gzip (its own
CI gate), never loads the template parser, and amo eject hands it over with the
runtime — the router you can uninstall.
Two honest notes: the browser tab spinner runs while load() is pending — that is
the platform's real progress indicator, not a page reload; and the URL commits before the
content arrives, which differs from React Router and may surprise you.
Anti-goals
These are closed decisions, not open questions. Knowing what a tool refuses to become tells you more than its feature list.
addEventListener on the element is the answer — identical semantics to vanilla, and no escape hatch to document.
Status
AmoJS is pre-release and built in the open. The core, compiler, CLI, router, server rendering and ownership model are done and tested; documentation and the public release are not.
html`` templates, mount. Runs in a browser with no build.amo build and amo eject.@amojs.dev/router, built on the Navigation API, pages receive resolved data.amo ssg renders pages on node. A page with no island ships zero script bytes. No hydration, ever.amo build --target server, a dynamic <title>, prebuilt single-file bundles, and @amojs.dev/vite for projects that want a bundler.amo ssg — documentation as live, pokeable demos rather than prose.FAQ
A build-time compiler for fine-grained reactive user interfaces; a complete compiled app is 1.98 KB all-in, runtime included. You write standard JavaScript using signals and html`` tagged template literals; the compiler turns those templates into direct DOM instructions — cloned templates, positional node walks, and one small effect per dynamic value.
No. That is the point. An AmoJS file is standard .js, so it loads over raw ESM with an import map and runs directly in the browser. The compiler is an optimizer you can adopt later; it makes the same code smaller and faster without changing its behavior.
Lit shares the template technique but re-runs the whole template function and dirty-checks each part on every update; AmoJS wakes only the effect whose signal changed. Svelte and Vue Vapor also compile to fine-grained updates, but they require a build and a custom file format. What is unique to AmoJS is the combination: standard .js that runs unbuilt, a twelve-name API, zero dependencies, and an eject command that lets you delete the framework and keep the output.
amo eject actually do?It compiles your project, copies the runtime source next to it, and rewrites every import to a relative path. The result is readable vanilla JavaScript with no bare module specifiers — verified by a test that executes the ejected app. You can commit that output and uninstall AmoJS entirely.
Not yet. The core, compiler, CLI, router, server rendering and ownership model are complete and covered by a large test suite with size and behavior gates in CI, but AmoJS is pre-release: it is published on npm as v0.9 and there is no documentation site yet. Use it today for experiments and prototypes; wait for v1.0 for anything you have to maintain.
Yes. The package ships generated .d.ts declarations, so signal(0) infers Signal<number> and assigning to a computed is a compile error. The runtime source itself stays JavaScript with JSDoc types, checked with tsc. Your own code can be TypeScript — compile it to JavaScript first, then run amo build, since the compiler parses JavaScript.
From amo — Shirazi Persian “عامو”, a warm and familiar way to address someone — and Latin amo, “I love”.
AmoJS is built in the open, one measured decision at a time. Read the source, run the benchmarks, or watch the repository for the v1.0 release.