Short Version
Most web apps turn database state into backend models, then DTOs, then frontend models, then components, then HTML. JST asks whether the backend and browser can both speak the final format directly.
A JST component is HTML plus JavaScript. The browser registers it as a real custom element. A server can emit that same component syntax in an ordinary HTML response.
Use JST When
You want the backend and frontend to share the same UI representation: HTML. JST fits static sites, SSR apps, HATEOAS apps, internal tools, progressively enhanced pages, quick prototypes including a single HTML file opened straight from disk, and apps whose state lives in the DOM, the URL, or the server rather than a client store.
Avoid JST When
You need a large client-owned SPA, a big catalogue of ready-made third-party components today, fine-grained reactive state graphs, or TypeScript-first component authoring.
Install
JST has three install stories: file:// Mode (open a single file from disk), Dev Mode (serve over HTTP with visible errors), and Prod Mode (precompile for production and strict CSP).
file:// Mode
jst.global.js (min: jst.global.min.js) is the whole runtime as one classic script: no modules, no server. Copy an example, generate a single HTML file, double-click it from disk, and it runs. It exposes window.JST.
<!-- Copy jst.global.min.js next to your HTML file... -->
<script src="jst.global.min.js"></script>
<!-- ...or load it from the CDN, pinned to a release tag. -->
<script src="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst.global.min.js"></script>
Dev Mode
Serve over HTTP, load jst.js as an ES module, enable dev: true, and write templates inline. The CDN works here too: the runtime's own imports resolve against the CDN URL, so one pinned import is the whole install.
<!-- Dev mode: serve over http://, then load the module runtime. -->
<script type="module" src="/jst.js"></script>
<script type="module">
import { configure } from '/jst.js';
configure({ dev: true });
</script>
<!-- No local copy at all: import straight from the CDN. -->
<script type="module">
import { configure } from 'https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst.js';
configure({ dev: true });
</script>
Prod Mode
Precompile templates into a module for strict CSP and lower startup cost. Runtime behavior still goes through the normal JST renderer. The runtime can come from the CDN here too: point --runtime at the pinned URL and the generated module imports it itself, so the templates file is the only script tag you add. For multi-file apps, see Bundling many components below.
# Production mode: precompile templates for strict CSP.
node tools/precompile.mjs components.html --out dist/templates.js --runtime ../jst.js
<script type="module" src="/jst.js"></script>
<script type="module" src="/dist/templates.js"></script>
# Or serve the runtime from the CDN: the templates module imports it directly,
# so the templates file is the only script tag on the page.
node tools/precompile.mjs components.html --out dist/templates.js --runtime https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst.runtime.js
<script type="module" src="/dist/templates.js"></script>
Bundling many components
Point the precompiler at every file that contains templates, server views included; it writes one module that registers them all. Pair it with the runtime-only build, which drops the in-browser compiler (about 40% smaller). For classic no-module pages, --global emits a plain script that registers through window.JST.
# Every template in views/ and components/, one output module.
node tools/precompile.mjs views/*.html components/*.html --out dist/templates.js --runtime /jst.runtime.js
<script type="module" src="/jst.runtime.js"></script>
<script type="module" src="/dist/templates.js"></script>
# Classic pages: --global output, paired with jst.runtime.global.js.
node tools/precompile.mjs components.html --out dist/templates.global.js --global
Component library (opt-in)
Two stylesheets and one fragment of component definitions. jst-layout.css is theme variables, a classless base, and the layout primitives (zero JavaScript); jst-components.css styles the component patterns; jst-components.html defines the five JST components (<jst-palette> <jst-tabs> <jst-toaster> <jst-combobox> <jst-table>). Minified copies (.min.css) sit next to the sources. See it live: the component gallery (every component in its own frame, re-skinnable to eleven framework looks, Astryx included) and the layout primitives (zero JavaScript). For what JST deliberately does not author, see the interop proofs: three chart libraries inside JST and Web Awesome components inside JST. Three more example pages round out the coverage audit: platform recipes, the patterns JST deliberately does not ship because the platform already covers them, shown as working native demos; template recipes, copyable JST templates for the handful of patterns where a component earns its keep; and server-driven interactions the HATEOAS way, the interactions that tempt a JSON endpoint — drag-and-drop reorder, inline edit, undo, job polling — done as forms, links and rendered HTML fragments, each with its round-trip shown on the wire.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst-layout.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst-components.min.css">
<!-- Runtime: the definitions auto-register on arrival. -->
<jst-include src="https://cdn.jsdelivr.net/gh/br3nt/jst@v0.7.13/jst-components.html"></jst-include>
# Strict CSP: precompile the same fragment instead.
node tools/precompile.mjs jst-components.html --out dist/jst-components.js --runtime /jst.runtime.js
Write Your First Component
A component is a <script type="jst"> block. JST registers it as a real custom element via customElements.define(): the template's name becomes the tag name, usable anywhere plain HTML works and inspectable in DevTools like any other element. The attributes list declares the values available inside the template.
<script type="jst" name="hello-name" attributes="name count">
<p>Hello, <strong>$(name)</strong>.</p>
<button onclick="el.count = (el.count || 0) + 1">
Clicked $(count || 0) times
</button>
</script>
<hello-name name="JST" count="0"></hello-name>
That exact template is registered on this page. Here it is running:
Template Syntax
The colors in code examples match this table. The template language is intentionally small: HTML stays HTML, and the dynamic parts are JavaScript.
| Syntax | Meaning |
|---|---|
$(expr) | Evaluate JavaScript and HTML-escape the result. |
$identifier | Shorthand for a single identifier interpolation. |
$ statement | Run a JavaScript statement line. |
${ ... } | Run a JavaScript block. |
.property="$(expr)" | Set a JavaScript property on a rendered element. |
on<event>="…statements…" | Add an event listener. The value is a plain function body (event in scope, this is the element), exactly like a native inline handler. |
jst-key="$(id)" | Preserve DOM identity in lists. |
jst-model="title" | Local form shorthand: read/write el.title. |
$(slot()) | Project the component's original children. |
<script type="jst" name="todo-list" attributes="items filter">
$ const visible = (items || []).filter(item => {
$ if (filter === 'done') return item.done;
$ if (filter === 'active') return !item.done;
$ return true;
$ });
<ul>
$ visible.forEach(item => {
<li jst-key="$(item.id)">$(item.text)</li>
$ })
</ul>
</script>
Handlers & Behaviour
An on<event> attribute value is a function body, exactly like a native inline handler: event is in scope and this is the element. In plain body HTML the browser compiles it; in a JST template the JST compiler does, and template values (item, el, your declared attributes) stay usable inside the handler.
<form onsubmit="event.preventDefault(); save()">
<input oninput="if (changed(event)) debounce(event, 300, () => search(this.value))">
<div onkeydown="keys(event, { Enter: () => open(item), Escape: () => close() })">
<p onreveal="el.emit('seen')">lazy region</p>
<button onclick.outside="el.open = false">menu</button>
JST provides helper functions for the patterns modern web apps keep needing: waiting for typing to pause, rate-limiting noisy events, reacting only to real changes, keyboard shortcuts. They are in scope inside every template handler body, and available as JST.fn.* (or module imports) everywhere else. This is the full list:
| Helper | Pattern it serves |
|---|---|
debounce(event, ms, fn) | Run fn once the events go quiet for ms: typeahead search, autosave. |
throttle(event, ms) | Rate-limit: returns true at most once per ms. Guard scroll and resize work with if (throttle(event, 500)) …. |
changed(event) | Compares event.target.value with the value it saw on the previous event for that control, starting from the control's initial value. Returns true only when they differ, so events that edited nothing (arrow keys, Shift) are skipped in active search. |
keys(event, map) | Keyboard shortcuts: keys(event, { Enter: () => save(), 'Meta+k': () => palette() }). |
commands(event, map) | Invoker Commands routing: oncommand="commands(event, { '--save': save, '--revert': revert })" dispatches on event.command; unmatched commands are ignored. |
Our helpers are ordinary functions, so you can add your own and use them the same way: define typeahead once in your app's JS, then call it from any attribute: oninput="typeahead(event)".
value can silently mean this.value. JST handlers do not recreate that scope chain: always write this.value. This is deliberate: the element's hundreds of DOM properties would otherwise shadow your template values.
How handlers interact with Content-Security-Policy (who compiles what, and what a strict policy changes) is covered in Handlers and CSP under Security And Production.
Data Flow
In raw top-level HTML, attributes carry the data (JSON arrays and objects included) and property assignment carries what JSON cannot: functions, class instances, DOM nodes. Inside a JST template, a dot-prefixed attribute assigns a JavaScript property (.item="$(item)") and on<event> attaches a listener.
Raw HTML + page JavaScript
<todo-list id="todos"
items='[{"id":1,"text":"Write docs","done":false}]'
ontoggle="this.items = this.items.map(item =>
item.id === event.detail.id ? { ...item, done: !item.done } : item)">
</todo-list>
<script>
// Updating later, or passing values JSON can't carry (functions,
// class instances, DOM nodes): assign the property.
document.getElementById('todos').items = loadedItems;
</script>
Inside a JST template
<script type="jst" name="todo-list" attributes="items">
$ (items || []).forEach(item => {
<todo-item
jst-key="$(item.id)"
.item="$(item)"
ontoggle="el.emit('toggle', event.detail)">
</todo-item>
$ })
</script>
$(...) expression, compiled only inside <script type="jst">). on<event> means the same thing everywhere: a function body. What varies is who compiles it: the JST compiler inside a template (render scope, strict-CSP safe), or the browser in raw top-level HTML (native inline handler, relaxed CSP). A $(...) form inside a handler body is a compile error.State, Forms, Lists
State Updates
Assigning a declared property publishes component state. Objects and arrays render even when the reference is the same, because assigning the same reference is treated as an explicit publish signal.
counter.count = counter.count + 1;
list.items.push(nextItem);
list.items = list.items;
list.items = [...list.items, nextItem]; // immutable-style update also works
Forms
For parent/server-owned state, keep the boundary explicit. For local draft state, use jst-model.
<!-- Parent-owned value: explicit attributes-down, events-up. -->
<input
.value="$(title)"
oninput="el.emit('title-change', this.value)">
<!-- Local draft value: reads and writes el.title. -->
<input jst-model="title">
Slots
Slots project the custom element's original light-DOM children. Light DOM means the nodes are ordinary page DOM, not hidden inside a shadow root.
<script type="jst" name="app-panel" attributes="title">
<section>
<h2>$(title)</h2>
<div class="body">$(slot())</div>
<footer>$(slot('footer', ''))</footer>
</section>
</script>
<app-panel title="Hello">
<p>Main content</p>
<button slot="footer">Save</button>
</app-panel>
Theming JST
Every JST stylesheet is variables first: override the --jst-* custom properties and the look re-themes while the structure stays put. This very page is the proof. Its palette, spacing, radius, and text size all derive from those variables, so the knob panel in the bottom-right corner re-themes what you are reading right now. Open it and move something.
The CSS-variable contract
A small set of roles, not a bag of one-off values:
| Variable | Role |
|---|---|
--jst-bg, --jst-surface | The page background and the raised surface that sits on it (cards, panels, menus). |
--jst-fg, --jst-muted | Body text and its quieter companion for labels, hints, and metadata. |
--jst-border | Hairlines and control outlines. |
--jst-accent | One accent. The shade ramp --jst-accent-400/600/700 derives from it with relative color syntax, so overriding the accent alone moves the ramp. Native controls pick it up through accent-color. |
--jst-ok, --jst-warn, --jst-error | Status roles for validation, alerts, badges, and toasts, each with a matching -fg for text on that color. |
--jst-space, --jst-ratio | One base step and one ratio derive the whole modular scale, --jst-space-3xs through --jst-space-2xl. Raise the base to loosen a whole app at once. |
--jst-radius (+ -s, -l), --jst-font-size, --jst-measure | Corner radius and its derived steps — -s for chips and small inner elements, -l for containers (cards, dialogs, popovers, toasts) — plus base text size and the max line length that keeps prose readable. Override --jst-radius and all three scale together. |
--jst-motion-fast, --jst-motion, --jst-motion-slow, --jst-ease | One motion personality in three durations — fast for hovers and toggles, the base step for dismissals, drawers and accordions, slow for large, page-level movement — plus the shared easing. A skin re-times the whole UI by overriding these. |
--jst-neutral-h, --jst-neutral-c | The hue and chroma of the neutral grey family. Set these two and all five neutral roles (--jst-bg, --jst-surface, --jst-fg, --jst-muted, --jst-border) re-temperature at once, cool to warm, in both light and dark. |
How to theme
Override the variables wherever you need them: at :root for the whole document, or on any element to re-theme just that subtree. data-scheme="light" or "dark" on <html> (or a subtree) is the entire dark-mode toggle, because the role variables are built on light-dark(). The framework theme skins in jst-components.css (data-theme="bootstrap|shadcn|pico|…") are nothing but custom-property overrides, which is why the same markup re-skins to another framework's look.
/* Whole document: one accent, looser spacing, softer corners */
:root {
--jst-accent: oklch(0.55 0.18 20);
--jst-space: 0.8rem;
--jst-radius: 0.5rem;
}
<!-- Just this subtree, with its own dark scheme -->
<section data-scheme="dark" style="--jst-accent: oklch(0.7 0.15 150)">…</section>
The cascade layer
Every rule JST ships lives in the jst cascade layer (@layer jst.theme, jst.base, jst.primitives, jst.components). Any unlayered app rule outranks it regardless of specificity, so overriding a default is a plain selector, never a specificity fight, and one property drops out locally with revert-layer. The knob panel leans on exactly this: it writes the variables as inline styles on <html>, and an inline style beats the jst.theme layer, so a knob always wins over the shipped default.
HATEOAS Fragments
JST's unusual feature is that a server response can define and use a component in the same HTML fragment. When inserted into a trusted auto-register root, the template registers and the element upgrades.
<!-- Server response body -->
<script type="jst" name="app-notice" attributes="message level">
<aside class="notice $(level)">$(message)</aside>
</script>
<app-notice level="info" message="Saved"></app-notice>
Use ordinary links and forms for whole-resource navigation: GET /orders/4471, GET /orders/4471/edit, POST /orders/4471/items, PATCH /orders/4471/items/8. Fragment swaps are an enhancement for places where partial updates are worth the extra moving parts.
jst-nav: enhance links and forms
The opt-in jst-nav.js library does exactly one thing: it upgrades elements that already act (a link navigates, a form submits) so their native action fetches a fragment instead of loading a whole page. The URL and verb come from the element's own href / action / method; adding any effect attribute opts it in, and it degrades to normal navigation with JS off.
<a href="/page/2" jst-target="#list">next</a>
<form action="/orders" method="post" jst-target="#list" jst-swap="beforeend">…</form>
<!-- A region whose content lives at a URL is a COMPONENT, not a decorated div -->
<jst-include src="/comments" when="visible"></jst-include>
Other effect attributes: jst-select (pull a subtree out of a full-page response), jst-push-url / jst-replace-url (history), jst-confirm, jst-transition (bare only: wrap the swap in a View Transition, whatever the jst-swap mode, the general form of the jst-swap="transition" alias, which stays innerHTML-only), jst-boost (boost every descendant link/form of a container), jst-target-4xx/5xx/error (route error responses), plus out-of-band swaps via jst-swap-oob in the response. Routed error responses land with jst-swap-4xx/5xx/error (default innerHTML): a form that re-renders itself on validation failure targets its own id with jst-swap-4xx="outerHTML", so the response contains the form's real root. Cancelable lifecycle events (jst:before-request, jst:before-swap) bubble from the element; a swap whose target is gone by write time emits jst:swap-missed instead of succeeding silently; jst:response-error carries the response body as detail.text, so a toast can show the server's message directly. jst-confirm asks through window.confirm by default; apps that ban browser dialogs assign JST.nav.confirm = (message, el) => boolean | Promise<boolean> and the attribute drives their inline UI instead.
Every other cause (keystrokes, reveal, polling, websockets) is plain JavaScript calling swap(target, url, options), the same pipeline the attributes use. When programmatic navigation should behave exactly like a clicked link (lifecycle events for your delegated listeners, confirm, history), call navigate(url, options) instead: it drives the full enhanced-element pipeline and returns the Response.
import { swap } from './jst-nav.js';
// active search: the handler helpers pace it, swap() lands it
const typeahead = (event) => {
if (!changed(event)) return;
debounce(event, 300, () => swap('#results', '/search?q=' + event.target.value));
};
// polling is an interval; a websocket push is a listener
setInterval(() => swap('#status', '/job/42'), 2000);
<jst-include>) and never evaluates attribute strings (values are inert URLs/selectors, so server HTML can't smuggle code). There is no trigger grammar to learn. Causes are events, timers, and components: things JavaScript already has.Server-Side Rendering Examples
These examples are for backend web frameworks that render HTML on the server. The point is not that JST needs those frameworks; it is that they can emit JST directly because the wire format is HTML.
Rails ERB
<task-row
task-id="<%= task.id %>"
title="<%= h task.title %>"
done="<%= task.done.to_json %>">
</task-row>ASP.NET Razor
<script type="jst" name="task-row" attributes="taskId title done">
<button onclick="el.emit('complete', taskId)">Complete</button>
</script>
<task-row task-id="@Model.Task.Id" title="@Model.Task.Title"></task-row>Plain PHP
<task-row
task-id="<?= htmlspecialchars($task['id']) ?>"
title="<?= htmlspecialchars($task['title']) ?>"
done="<?= json_encode($task['done']) ?>">
</task-row>Laravel Blade
<task-row
task-id="{{ $task->id }}"
title="{{ $task->title }}"
done="@json($task->done)">
</task-row>Go html/template
<task-row
task-id="{{ .Task.ID }}"
title="{{ .Task.Title }}"
done="{{ .Task.Done }}">
</task-row>Django / Jinja-style templates
<task-row
task-id="{{ task.id }}"
title="{{ task.title }}"
done="{{ task.done|yesno:'true,false' }}">
</task-row>Rust Askama
<task-row
task-id="{{ task.id }}"
title="{{ task.title }}"
done="{{ task.done }}">
</task-row>Security And Production
Trust Boundary
$(expr)escapes HTML by default.url(value)blocks dangerous URL schemes in URL-bearing attributes.trustedHTML(value)bypasses escaping. It is the only opt-out-of-escaping helper; only use it for HTML your app produced or sanitized.- Fetched JST templates are code. Scope them with
autoRegisterRootand allowlist names inresolveTemplate.
<p>$(comment)</p>
<a href="$(url(userLink))">go</a>
<article>$(trustedHTML(renderedMarkdown))</article>
Names, never code (the script-gadget rule)
jst-nav and jst-behaviors read attribute values from the live DOM, including server-rendered and swapped-in HTML, so their values are only ever inert strings: URLs, CSS selectors, names. This is a hard line, not a style choice. A framework that evaluates DOM attribute strings becomes a script gadget: on a site that deployed strict CSP precisely to neutralise injected onclick= handlers, an attacker who achieves HTML injection could ride the framework around the policy. JST refuses to be that gadget.
Handlers and CSP
In plain body HTML, native oninput="typeahead(event)" is evaluated by the browser: plain platform code that needs a relaxed CSP. Under a strict CSP, the same behaviour moves into a template (where handlers compile to addEventListener, with no nonce and no unsafe-inline) or a script. jst-lint --csp lists every native inline handler when you're ready to make that move.
Component events and synthetic events in body HTML (an onitem-selected or onreveal attribute outside a template) require JST to evaluate the attribute string, because the browser only wires its own event names. So they're opt-in and loudly named:
configure({ unsafeInlineHandlers: true });
How far to take strict CSP
The strict-CSP ladder: plain pages need nothing special; interactive regions become precompiled components, removing unsafe-eval; at the extreme, the whole page becomes one root component. Know when you've overcorrected: a single app-shell template is a SPA. You lose native cross-document view transitions and simple server-rendered pages for a constraint you may not actually have. If your CSP isn't strict, you don't need any of this; stop at the rung your policy requires and no further.
Configuration
import { configure } from './jst.js';
configure({
dev: location.hostname === 'localhost',
autoRegisterRoot: document.getElementById('trusted-fragments'),
resolveTemplate(name) {
if (!name.startsWith('app-')) return null;
return `/components/${name}.html`;
},
});
Testing & TypeScript
Testing: components are real custom elements in light DOM. Render them into a DOM (jsdom with custom elements, or Playwright against a served page), set attributes and properties, dispatch events, and assert on innerHTML. No special test renderer. TypeScript: the repo ships hand-written .d.ts files for the runtime API, window.JST, JST.nav, and the handler helpers, so consumption is typed without a build step. Typed template expressions are a known gap.
Browser Support
JST targets modern evergreen browsers and uses platform features directly: Custom Elements v1, ES modules, MutationObserver, queueMicrotask, CustomEvent, and modern DOM APIs. Internet Explorer and very old pre-evergreen browsers are out of scope.
The default browser compiler uses new Function, which Content Security Policy treats as eval. That is a CSP concern, not a browser compatibility concern. Use precompiled mode for strict CSP.
Known Gaps
- Formatter / tree-sitter / typed expressions: planned. VS Code diagnostics and highlighting exist now.
- Keyed reconciliation: tested, but the most intricate part of the runtime; it should keep gaining adversarial tests.
- Transitions: CSS-driven enter/leave works for keyed lists; richer move/FLIP behavior may come later.
- Light DOM: deliberate. There is no style or id encapsulation, so prefer classes and derive ids from attributes.
- Handler bodies and $-forms: a handler body cannot contain
$(…)forms or lines starting with$. Use a named function for complex handlers.
Tooling
The VS Code extension under tooling/ provides syntax highlighting, diagnostics, and a language server. Planned next: marketplace packaging, formatter support, typed expression checking, and a tree-sitter grammar.