No virtual DOM. No hooks. No surprises.
marisjs is a signals-based full-stack framework that compiles to plain JavaScript. A strict, machine-checked subset of TSX with one state primitive, server prerendering, API routes, and a 5 KB runtime. Errors die at build time, not on your users' screens.
$ npm install marisjs @marisjs/runtime
$ npx marisjs init
wrote package.json (dev / build / serve scripts)
wrote .gitignore (excludes .env)
wrote .env.example (no real values)
$ mkdir -p src/pages
$ npm run dev
-> watching src for changes
-> http://localhost:3000
$ npm run build
validated 12 files, 0 errors
built 3 pages, 2 islands
wrote runtime -> runtime.mjs (5.4 KB) Three decisions.Everything else follows.
marisjs removes the abstractions that cause runtime bugs, then machines-check what is left.
Compiled to plain JavaScript
Components compile to direct DOM construction and per-value bindings. Nothing to diff, nothing to reconcile, nothing to unload from the page.
One state primitive
signal() for state, computed() for derivation. No dependency arrays, no stale closures, no rules-of-hooks linting.
Fails at build time
Every rule is machine-checkable. The validator rejects illegal code with an error code and a fix hint - before it ever reaches a browser.
No virtual DOM
Direct DOM construction with per-value bindings. Nothing to diff, nothing to reconcile.
Islands, not apps
Server pages prerender to static HTML. Islands hydrate where interactivity actually exists.
A 5 KB runtime
Zero-dependency JavaScript embedded in your output at compile time. No framework shipped.
Pure TSX
Every valid marisjs file is valid TypeScript. Your editor and type checker already speak the language.
One way to do it
One canonical form for lists, conditionals, state, and handlers. No competing patterns to argue about.
Full-stack by default
pages/ maps to routes, api/ to HTTP handlers. await data() fetches at build time, env() keeps secrets server-side.
Same feature.Half the ceremony.
A counter with shared state, as written in a classic hook-based framework versus marisjs.
// App.tsx - with hooks
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
document.title = 'Count: ' + count;
}, [count]);
useEffect(() => {
setCount((c) => c + step);
}, [step]);
const inc = () => setCount((c) => c + step);
const dec = () => setCount((c) => c - step);
return (
<div className="counter">
<p>Count: {count}</p>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
<select value={step} onChange={(e) => setStep(Number(e.target.value))}>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</div>
);
}
// src/islands/Counter.tsx - marisjs
// @runsOn client
import { signal, computed } from '@marisjs/runtime';
type Props = {};
export function Counter(props: Props) {
const count = signal(0);
const step = signal(1);
const double = computed(() => count.value * 2);
function inc() { count.set(count.value + step.value); }
function dec() { count.set(count.value - step.value); }
function setStep(e: any) { step.set(Number(e.target.value)); }
return (
<div class="counter">
<p>Count: {count.value} (double: {double.value})</p>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
<input type="number" value={step.value} onInput={setStep} />
</div>
);
}
Boring on purpose
Frameworks get dangerous when they let you write code in ten different ways and guess what each one does. marisjs is built on five rules, and every single one is enforced by the compiler - not by a style guide:
- 01One canonical way to do each thing. No competing patterns.
- 02No hidden execution order. Nothing runs unless it is written down.
- 03No action at a distance. Everything a file touches is visible in that file.
- 04Fail at validation time, not runtime.
- 05Every rule is machine-checkable - or it is not a rule.
Invalid code is rejected with a specific error code and a fix hint. Your build is the compiler's verdict on your code, and it is a harsh judge.
Ship something small
Two commands, a pages/ folder, and one state primitive. Install marisjs and build your first island today.
npm install marisjs Compiled to plain JavaScript.Running on this page.
The dashboard below is a marisjs client island - the same code you see here, compiled to direct DOM calls. No framework is loaded to run it. Drag the sliders.
// @runsOn client
import { signal, computed } from '@marisjs/runtime';
type Props = {};
export function Playground(props: Props) {
const energy = signal(72);
const focus = signal(55);
const depth = signal(88);
const clicks = signal(0);
const maxVal = computed(() => Math.max(energy.value, focus.value, depth.value, 1));
const total = computed(() => energy.value + focus.value + depth.value);
const avg = computed(() => Math.round(total.value / 3));
const grade = computed(() => {
const v = avg.value;
return v >= 80 ? 'smooth' : v >= 55 ? 'steady' : 'rough';
});
const items = [
{ id: 'energy', label: 'energy', sig: energy },
{ id: 'focus', label: 'focus', sig: focus },
{ id: 'depth', label: 'depth', sig: depth },
];
function setEnergy(e: any) { energy.set(Number(e.target.value)); }
function setFocus(e: any) { focus.set(Number(e.target.value)); }
function setDepth(e: any) { depth.set(Number(e.target.value)); }
function bump() { clicks.set(clicks.value + 1); }
function resetAll() {
energy.set(72);
focus.set(55);
depth.set(88);
clicks.set(0);
}
return (
<div class="play-card">
<div class="play-card-head">
<span class="play-card-title">playground.tsx</span>
<span class="play-live">island hydrated</span>
</div>
<div class="play-body">
<div>
<div class="slider-row">
<span class="slider-label">energy</span>
<input type="range" min="0" max="100" value={energy.value} onInput={setEnergy}></input>
<span class="slider-val">{energy.value}</span>
</div>
<div class="slider-row">
<span class="slider-label">focus</span>
<input type="range" min="0" max="100" value={focus.value} onInput={setFocus}></input>
<span class="slider-val">{focus.value}</span>
</div>
<div class="slider-row">
<span class="slider-label">depth</span>
<input type="range" min="0" max="100" value={depth.value} onInput={setDepth}></input>
<span class="slider-val">{depth.value}</span>
</div>
</div>
<div class="bars">
<For each={items} key={(item) => item.id}>
{(item) => (
<div class="bar-track">
<div class="bar-fill" style={'width:' + Math.round((item.sig.value / maxVal.value) * 100) + '%'}>
{item.label}
</div>
</div>
)}
</For>
</div>
<div class="play-totals">
<div class="total-chip">
<div class="t-label">total</div>
<div class="t-value"><em>{total.value}</em></div>
</div>
<div class="total-chip">
<div class="t-label">average</div>
<div class="t-value">{avg.value}</div>
</div>
<div class="total-chip">
<div class="t-label">condition</div>
<div class="t-value">{grade.value}</div>
</div>
</div>
<div style="display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap">
<div style="display:flex;gap:10px;align-items:center">
<button class="counter-btn" style="width:auto;padding:0 18px;font-size:15px" onClick={bump}>+1</button>
<span class="slider-label">clicks: <span class="slider-val">{clicks.value}</span></span>
</div>
<button class="counter-reset" onClick={resetAll}>reset</button>
</div>
</div>
</div>
);
}
The card above is hydrated from exactly this source: three signals, two computed values, one