AmoJS

Open source · v0.9 on npm · pre-release

Compiles to the vanilla JavaScript you would have written

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.

  • 1.98 KBwhole app, min+gzip
  • 0runtime dependencies
  • 12names in the whole API
  • .jsno custom file format

How it works

Standard JavaScript in. Vanilla DOM code out.

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.

Your source runs as-is, no build
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);
After amo build real compiler output
import { 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

A framework you can uninstall

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.

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.

Runs with no build step

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 framework

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

Identical semantics, compiled or not

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.

No custom file format

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.

Zero dependencies, forever

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

Numbers, not adjectives

Every figure below is a gate in continuous integration, not a claim in a README. If one regresses, the build goes red.

GateMeasuredBudget
Compiled counter app, all-in1,982 B≤ 2,048 B
Framework minus template parser, bundled2,520 B≤ 2,560 B
No-build app, parser included4,020 B≤ 4,096 B
Identity benchmark — compiled ÷ hand-written vanilla1.087×≤ 1.10×
DOM mutations vs a hand-written appexactly equalparity gate
List moves — swap / reverse / rotate2 / n−1 / 1LIS-minimal

Measured on core 0.6.x. “All-in” means bundled, minified and gzipped with the runtime included — the way frameworks quote size.

Against Lit — the nearest neighbour

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

ScenarioAmoJSLit 3.3
1 of 5 bindings changes → expressions evaluated15
Churn — the same values written again05
All 5 change — a genuine tie55
Shipped bytes, min+gzip2,336 B6,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

The whole API is twelve names

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.

State

signal()computed() effect()isSignal()

Template

html``each()ref=

Lifetime

mount()root() onMount()onCleanup()

Scheduling

flushSync()tick()

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

You never write a loading state again

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.

The app three names: router, redirect, link
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'));
A page data arrives resolved
// 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

What AmoJS will never have

These are closed decisions, not open questions. Knowing what a tool refuses to become tells you more than its feature list.

  • Hydration. Hydration exists so a framework’s client runtime can re-attach to server HTML. AmoJS compiles to the code you would have written; there is no runtime to re-attach. Server rendering, when it lands, is islands — static HTML plus interactive pieces that build their own DOM.
  • A virtual DOM. Not as an option, not behind a flag.
  • Proxy-based deep reactivity. No human writing vanilla JavaScript wraps their arrays in a Proxy, so magic mutation cannot be ejected as “code you would have written”. Replace the array, or put a signal inside each item.
  • Global event delegation. A real addEventListener on the element is the answer — identical semantics to vanilla, and no escape hatch to document.
  • A clever compiler. It rewrites templates and import specifiers, and touches nothing else. It never reasons about module boundaries, lazy loading or dead code — which is why it can be trusted next to any other tool in your pipeline.

Status

Honest state of the project

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.

  • v0.1 ✓Reactive core — signals, computed values, html`` templates, mount. Runs in a browser with no build.
  • v0.2 ✓The compiler — parse → IR → codegen, plus the identity benchmark that caps output at +10% of hand-written vanilla.
  • v0.3 ✓Blocks — conditionals and keyed lists, behaving identically in both modes.
  • v0.4 ✓The CLI — amo build and amo eject.
  • v0.5 ✓Composition — components, props, the ownership tree, and a dispose suite that proves no leaks.
  • v0.6 ✓Size and speed — CI budgets, DOM-work parity, benchmarks against Solid, Vue Vapor, alien-signals and Lit.
  • v0.7 ✓Public packages on npm, and the router — @amojs.dev/router, built on the Navigation API, pages receive resolved data.
  • v0.8 ✓Server rendering as islands — a second codegen over the same IR emits strings, so amo ssg renders pages on node. A page with no island ships zero script bytes. No hydration, ever.
  • v0.9 ✓Rendering per request with amo build --target server, a dynamic <title>, prebuilt single-file bundles, and @amojs.dev/vite for projects that want a bundler.
  • nextThis site, built with amo ssg — documentation as live, pokeable demos rather than prose.
  • v1.0Public release — documentation, a live playground on this domain, and a stable API.

FAQ

Questions people ask first

What exactly is AmoJS?

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.

Do I need a build step to use it?

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.

How is it different from Lit, Svelte, Solid or Vue?

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.

What does 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.

Is it production ready?

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.

Does it support TypeScript?

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.

Where does the name come from?

From amo — Shirazi Persian “عامو”, a warm and familiar way to address someone — and Latin amo, “I love”.

Compiles to the vanilla JS you would have written.

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.