105. No framework at all
The question this chapter answers: what does an fsr application look like with no component framework in it, where does the interactivity go and how does a region talk to the server without a payload or an island?
The browser is the mounter
Chapter 104 put a Vue component where a React one had been and the seam held: a template places it, the server writes its marker, a mounter takes it in the browser. The cheapest case on that spectrum has no mounter. A custom element is defined once by a module the browser runs and upgraded wherever the parser finds its tag. The server can write that tag and everything inside it, because it is markup like any other; the browser does the rest when the definition arrives.
Two examples carry this chapter. The tool library, toolshed_web_ts, is built that way. Every route is a template, every interactive piece is a .ts file under src/elements/ calling customElements.define; the regions that reach the server are htmx attributes. Nothing mounts, nothing hydrates and the one line in generated/islands.ts registers an element definition rather than a component. The bundle is src/**/* and two generated files; the import map is the client, its store and htmx. The noticeboard, noticeboard_tera, goes one step further at the end of the chapter: its pages are not TSX templates at all but Tera files the host renders from disk.
Writing a custom element in a template
A template writes the element the way it writes a div, with its light DOM inside:
<shed-tally count={reserved}>
<button type="button" className="tally" aria-label="reserved" aria-expanded="false">
{reserved} reserved
</button>
<div className="tally-panel" hidden>
<p>Reservations are kept in the session cookie.</p>
</div>
</shed-tally>The lowerer takes a hyphenated tag as an element and its attributes as attributes, so the server renders the button with the count in it before any script runs. The dialect's declarations, @snapfire/fsr-authoring/template, type a hyphenated tag as a custom element whose attributes are its own, so <divv> is still the typo the checker catches while <shed-tally count={n}> passes. An attribute the declarations do not name takes a scalar, children, a style object or a handler. An array of objects fails the typecheck, since an attribute on an element nothing hydrates is text. An hx-get on an anchor passes for the same reason any hyphenated attribute does.
The element's module wires what the server wrote:
import { get, subscribe } from "@snapfire/fsr-client/store";
import { reservedCount } from "../store.js";
class ShedTally extends HTMLElement {
#stop: (() => void) | null = null;
connectedCallback(): void {
const button = this.querySelector<HTMLButtonElement>("button.tally");
const panel = this.querySelector<HTMLElement>(".tally-panel");
if (!button || !panel) return;
const toggle = () => {
panel.hidden = !panel.hidden;
};
const show = (count: unknown) => {
if (typeof count === "number") button.textContent = `${count} reserved`;
};
button.addEventListener("click", toggle);
show(get(reservedCount));
const unsubscribe = subscribe(reservedCount, show);
this.#stop = () => {
button.removeEventListener("click", toggle);
unsubscribe();
};
}
disconnectedCallback(): void {
this.#stop?.();
this.#stop = null;
}
}
customElements.define("shed-tally", ShedTally);get and subscribe are the whole store adapter. React reads the store through a hook and Vue through a ref, because each framework has its own idea of a reactive value; an element has none, so it reads the value once and takes a callback for the changes. The get comes first because subscribe only hears what changes after it: the server wrote this count into the button, but a property or anything else the server did not write would stay empty until the key next moved. The layout's loader seeds the key with export const store, exactly as it would for a React island; the count follows the store from then on.
disconnectedCallback removes the listener and the subscription that connectedCallback added. A morph that moves an element disconnects it and connects it again, so without the stop a moved tally would carry two click listeners that cancel each other out; a removed one would stay subscribed and keep being written.
A shadow root the server writes
An element that wants its own styles has a shadow root. The parser attaches one from a <template shadowrootmode="open"> inside the element, before any script runs. The element's template lives beside its class in elements/loan-planner.tsx. The server writes it inside every <loan-planner> it renders:
export default function LoanPlanner({ deposit, days, max, disabled }: { deposit: number | bigint; days: number; max: number | bigint; disabled?: boolean }) {
return (
<>
<style>{":host { display: block } :host([disabled]) { background: #f7f8fa } output { font-weight: 600 }"}</style>
<label>
{disabled ? "Borrowed for" : "Borrow for"}
<input type="range" name="days" min="1" max={`${max}`} value={`${days}`} disabled={disabled} />
<output>{days} days</output>
</label>
</>
);
}The tool page places the tag and nothing else, <loan-planner name="days" deposit={tool.deposit} disabled={reserved} max={tool.days} days={days} />. The file name is the tag. The template's props are the element's attributes, so an array or an object reaches the template without becoming an attribute. The build types the tag with those props in generated/elements.d.ts, so a missing days or a max of the wrong type fails the typecheck at the page, while name, which the class reads and the template does not take, passes. A template with state or a handler stops the build, since the class owns the behaviour.
The planner is styled and laid out from the first paint, with no framework and no stylesheet to link. One thing to know: innerHTML attaches no declarative shadow roots, so an element that arrives inside an htmx swap finds its template as an ordinary child. shadowOf from @snapfire/fsr-client/elements answers both cases, the root the parser attached or one attached from the template:
const root = shadowOf(this, this.#internals);The navigator writes what it applies with setHTMLUnsafe where the browser has it, so a page reached by a link keeps its shadow roots as well.
The root is open unless the template declares its own. Returning a <template shadowrootmode="closed"> makes the planner's root closed. shadowrootdelegatesfocus on it hands focus to the range when the host is focused:
return (
<template shadowrootmode="closed" shadowrootdelegatesfocus>
<style>{":host { display: block } output { font-weight: 600 }"}</style>
<label>
Borrow for <input type="range" name="days" min="1" max={`${max}`} value={`${days}`} />
</label>
</template>
);The build reads that <template> as the shadow root. Anything on it besides the four shadowroot attributes stops the build, since the parser drops the template element once it becomes the root. A closed root is not on this.shadowRoot. The planner already holds its internals for the form value, which is why the call above passes this.#internals. An element without them would get null from shadowOf.
The state is in the markup, not in a script that runs after paint. reserved comes from the loader, so the server writes disabled on the host and on the range, with the label reading "Borrowed for" from the first byte. A boolean attribute is written bare when it is true and left out when it is false, which is what :host([disabled]) and a disabled control each want.
A value inside a shadow root that the form posts
A control in a shadow root has no form owner, so the slider is not submitted by the form around it however it is nested. An element that wants to be a field says so and supplies its own value:
class LoanPlanner extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#show(range: HTMLInputElement) {
this.#internals.setFormValue(range.value);
}
}With name="days" on the host, days is in the posted body under that name, natively and through htmx alike, since both build the form data from the form. The action reads it as it reads any other field, clamps it to what the shed lends that tool for and writes the agreed length into the session, so the page that comes back says what was actually agreed rather than what the tool's limit is.
A definition that waits until its element is in view
Everything above is defined when the entry module runs, which is what a masthead wants and not what a panel at the bottom of the page wants. An element has no mount, so what a timing can defer is its definition. That is what a template asks for:
<Island when="visible" define="@src/elements/time-ago.ts">
<ul>
{loans.map((loan) => (
<li key={loan.tool}>
<time-ago datetime={loan.back}>back {loan.back}</time-ago>
</li>
))}
</ul>
</Island>The child is an element, not a component. The server writes its markup inside the island marker, so the list is readable with no script at all: back 2026-09-14 until the definition lands, back in 2 days after it. The build registers the module with defineMounter, whose mount does nothing, so the island machinery imports it when the panel scrolls into view and every element inside upgrades itself. The module needs an export to be imported dynamically; the class is the obvious one.
Islands in this application need nothing more than that: one marker, no props script, no mounter and no framework.
A region that asks the server for markup
An island round trip carries a payload the client applies. htmx carries none: an attribute names a URL, the response is markup and it is swapped into a target. What it needs from the host is one segment of a route as HTML with nothing around it. That is what __fragment in the query asks for:
GET /?category=Garden&__fragment the page segment, filtered by the query it carried
GET /tool/3?__fragment=loans the parallel slot named loans, wherever it sits on the route
GET /?__fragment=nope 404, no slot named `nope` on this routeThe host renders the whole route for either, layouts included, waits for every deferred segment rather than streaming a fallback, picks the segment out of the tree and writes it with no shell, no segment delimiters and no sidecar. Loaders never see the key; ctx.query and the segment keys are what they would be for a document. The shelf chips are that request as attributes:
<a href="/?category=Garden" hx-get="/?category=Garden&__fragment" hx-target="closest .page" hx-swap="outerHTML" data-sf-native>
Garden
</a>data-sf-native tells the navigator this anchor is not its own. The loans panel polls the same way, hx-get="?__fragment=loans" with hx-trigger="every 15s"; the <time-ago> elements inside each fresh fragment upgrade as they land, since the browser defines the element once and applies it everywhere.
A form that gets its page back
An action posted as a form is answered with a redirect to the page that posted it; the tera application in the examples posts its forms that way with no JavaScript at all. When the action's URL carries __fragment, the redirect carries it too:
<form method="post" action="/_sf/action/tool.$id.reserve" hx-post="/_sf/action/tool.$id.reserve?__fragment" hx-target="closest .page" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value={csrf_token ?? ""} />
<input type="hidden" name="tool_id" value={tool.id} />
<loan-planner name="days" deposit={tool.deposit} disabled={reserved} max={tool.days} days={days} />
<button type="submit">Reserve it</button>
</form>htmx posts, follows the 303 to /tool/3?__fragment and swaps in the tool page rendered from the session the action just wrote, with the button now saying the opposite. Without JavaScript the same form posts natively and lands back on the document. csrf_token is a prop every page and layout may read, minted for anonymous sessions too when [session] csrf = "always" is set, which a form anonymous visitors post needs.
Two libraries, one document
A fragment ends with the same inert seed script a document carries, so the store follows the server through htmx exactly as it does through a payload. Making the two libraries aware of each other is one line:
import htmx from "htmx.org";
import { boot, enableNavigation } from "@snapfire/fsr-client";
import { bindHtmx } from "@snapfire/fsr-client/htmx";
boot();
enableNavigation();
bindHtmx(htmx);fsr use app htmx writes the map line, vendors htmx and prints those three lines for main.ts; fsr new --with htmx writes them into the scaffold's own. htmx is passed in rather than imported by the client, so the binding takes whatever version the import map names. Both directions are needed, which is what that one line wires up. After htmx swaps, adopt reads the seeds nothing has read yet, which is how the masthead count moves for a page the layout was never re-rendered for; scan would mount any island the fragment placed. After the navigator applies a payload it dispatches sf:navigate plus sf:fill for each deferred segment it fills, so htmx processes the markup the navigator wrote. Leave that second direction out and a reserve form reached by clicking a tool name is markup htmx never saw: the browser posts it natively and the document reloads. That is the one way this arrangement fails. It fails visibly.
A page that is a Tera template
The tool library's pages are TSX with no state, which the build lowers to a tree the host walks. A page can skip the lowering altogether and be a template the host renders from the file. Put page.tera where page.tsx would go and the route is the same route:
routes/
layout.tera the frame, placing the page with {{ slot(name="content") }}
layout.loader.ts what the frame's footer counts
page.tera /
page.loader.ts the notices
notice/[id]/
page.tera /notice/{id}
page.loader.ts one notice, plus paths for the prerender
templates/
nav.tera a partial, included by this pathThe loader is unchanged. It is TypeScript, lowered and run by the host like every loader in this guide. What it returns is the template's context, so the index writes {% for notice in notices %} over the same notices a page.tsx would have taken as a prop. params, identity and locale reach the template through the loader, not directly. The layout places the page where a TSX layout writes {children}:
<div class="board">
<header>{% include "templates/nav.tera" %}</header>
<main>{{ slot(name="content") }}</main>
<footer>{{ notices | length }} notices, served from templates under <code>routes/</code>.</footer>
</div>Every .tera under app/ is loaded into one Tera and named by its path under the app, which is why the include names templates/nav.tera and why a partial can sit anywhere outside vendor/, dist/ and generated/. extends resolves the same way. The build has nothing to lower, bundle or typecheck for a template, so the report lists it as template; tsconfig.build.json lists the file among its includes and snapfirec compiles nothing from it. The loader and any actions.ts beside it are lowered as usual and a form in the template posts to an action the way the tool library's reserve form does. A template places an island with {{ island(module="src/ui/Thing.tsx#default") }}; the build reads the literal and bundles that module, so an island can still sit inside a page nothing else in the application hydrates.
The host reads the templates at boot and refuses a plan naming a template the tree does not hold, so a renamed file is a boot error rather than an empty page. fsr prerender app treats a template route like any other: the index reads nothing from the request, so it is written once. The notice page's loader exports paths, so one file per notice is written beside it and the host answers those from disk while rendering any other id live. There is no Rust project in the noticeboard and no main.rs; fsr serve app is the whole server. An application that owns its binary registers the same evaluator through HostBuilder::evaluator, which is how uni in chapter 106 renders its layout. A bare application takes the direction with fsr use app tera, whose example is a page.tera beside its loader.
The lab
Run fsr build app in the tool library and read app/generated/islands.ts: one registration, an element definition with defineMounter, no mounter imported from any framework. Read the report: every route module is static. View the source of /tool/3 before the scripts run: the planner's shadow root is there inside its <template>, the form carries the token and the only <sf-i> on the page is the loans list, holding the markup its definition will upgrade.
Ask for fragments with curl, as above. The page fragment starts at <section and ends with the seed script; the weather slot is its error boundary alone; the unknown slot is one line.
Open the shelves in a browser, open the tally panel, click a tool and reserve it. The masthead was never touched and the panel is still open; the count moved because the fragment carried the seed. Then take the sf:navigate listener out of main.ts, rebuild and do it again: the document reloads on the reserve. Put it back.
Add a useState to routes/page.tsx and build: the page stops being static, so the registry would mount it through React. The build stops with the error chapter 104 shows, since this import map has no React either. The rule from chapter 104 is the same rule here.
Then the noticeboard. Run fsr build app there and read the report: the two pages and the layout are template, the three loaders are lowered and generated/islands.ts registers nothing. Run fsr prerender app and count the files under dist/prerender: the index plus one per notice, from paths. Start fsr serve app and ask for /notice/bins with curl -i: the answer carries x-sf-prerendered: 1. Ask for /notice/nope: rendered live, with the template's else branch. Rename templates/nav.tera and start the server again: it refuses at boot naming the template the layout includes and cannot find.