Server-rendered pages, small interactive islands, links that do not reload, and slow sections that stream in.
A typical page is mostly content: headings, text, lists, links. Only a few parts need JavaScript: a search box, a counter, a form that submits without leaving the page. Amoxi is built around that observation. Pages render on the server to plain HTML, and only the interactive parts, called islands, send code to the browser.
In this article we add a stats page to the notes app from article 1. It has a filter box that updates as you type, a slow section that streams in, and links that do not reload the page. The whole page costs less than 6 kB of JavaScript.
A page is a method that returns JSX
Create src/pages/stats-page.tsx:
import { Controller, Page } from 'amoxi';
import { NoteService } from '../notes/note-service';
import { Layout } from '../ui/layout';
@Controller()
export class StatsPage {
constructor(private readonly notes: NoteService) {}
@Page('/stats')
page() {
const notes = this.notes.list();
return (
<Layout title="Stats">
<h1>Stats</h1>
<p>You have {notes.length} notes.</p>
</Layout>
);
}
}
It looks like React, but it runs only on the server and compiles to fast string building. There is no virtual DOM and no hydration of the page. Three differences from React to remember:
- Write
class, notclassName, andfor, nothtmlFor. It is HTML. - Values are escaped automatically, so text from users cannot inject markup.
- A page can be
async, returnHttpResponse.redirect('/login'), or usewithStatus(404, ...)as we did in article 1.
Open http://localhost:8080/stats. Disable JavaScript in your browser and reload: it still works, because it is just HTML.
Islands: the interactive parts
An island is a component in src/islands/, one per file, exported as default. Create src/islands/NoteFilter.tsx:
import { For, computed, signal } from 'amoxi';
interface Item {
id: number;
text: string;
}
export default function NoteFilter(props: { notes: Item[] }) {
const [query, setQuery] = signal('');
const shown = computed(() => {
const q = query().trim().toLowerCase();
return props.notes.filter((n) => n.text.toLowerCase().includes(q));
});
return (
<div class="note-filter">
<input
type="search"
placeholder="Filter notes"
onInput={(e: Event) => setQuery((e.target as HTMLInputElement).value)}
/>
<p>{() => `${shown().length} of ${props.notes.length} notes`}</p>
<ul>
<For each={shown} key={(n) => n.id}>
{(n) => <li><a href={`/notes/${n.id}`}>{n.text}</a></li>}
</For>
</ul>
</div>
);
}
The building blocks:
-
signal('')returns a getter and a setter. Callquery()to read,setQuery('x')to write. -
computed(...)derives a value from signals and recalculates only when they change. - A function in JSX, like
{() => ...}, is a live binding: only that text node updates when its signals change. The component itself runs once; there are no re-renders. -
Forrenders a list and, thanks tokey, keeps existing rows when the list changes.
Put it on the page:
import { Island } from 'amoxi';
import NoteFilter from '../islands/NoteFilter';
// inside page():
const items = notes.map((n) => ({ id: n.id, text: n.text }));
<Island of={NoteFilter} notes={items} load="visible" />
The island renders on the server as part of the page, so the list is visible immediately, then comes alive in the browser. Its props travel as JSON, so pass plain data, not class instances or functions.
load decides when its JavaScript downloads: load (straight away, the default), idle (when the browser is idle) or visible (when it scrolls into view). An island below the fold costs nothing until someone gets there.
What the browser downloads
npm run build prints the bill:
ok Browser: 3 islands, 1 stylesheet(s) in 69ms; JS 5.8 kB + CSS 1.4 kB gzipped
/_amoxi/AddNote-4TEJCMUS.js 0.6 kB gz
/_amoxi/Counter-QDRSN6L4.js 0.2 kB gz
/_amoxi/NoteFilter-RL7FIDIO.js 0.4 kB gz
/_amoxi/chunks/chunk-6AX4GTBJ.js 4.4 kB gz
Our filter is 0.4 kB. The shared runtime (signals, island loading, navigation) is about 4 kB and is cached across pages. Each page loads only the islands it uses.
Islands run in the browser, so they cannot import server code. Import node:fs or a service with a database connection into an island and the build stops, naming the file and the import.
Links that do not reload the page
You get this without writing anything. When someone clicks a link to another page of your app, Amoxi fetches the new page, swaps it in place, updates the title and the address bar, moves focus for screen readers, and cleans up the old islands. Back and forward restore the scroll position. Links are prefetched when hovered or focused, so the next page is often ready before the click.
To prefetch a link as soon as it scrolls into view:
<a href="/about" data-prefetch="visible">About</a>
To force a normal page load for one link, add data-reload. From an island, after changing data, call navigate:
import { navigate } from 'amoxi';
await navigate(location.pathname, { replace: true, keepScroll: true });
That re-renders the current page in place with fresh data. The AddNote island from the starter app does exactly this after saving a note.
Streaming slow sections
Some data is slow: a report, an external API, a big query. You do not want the whole page to wait for it. Make a service that takes its time, src/stats/stats-service.ts:
import { Singleton } from 'amoxi';
export interface Stats {
notes: number;
words: number;
}
@Singleton()
export class StatsService {
/** Pretends to be a slow report query. */
async load(): Promise<Stats> {
await new Promise((r) => setTimeout(r, 1500));
return { notes: 3, words: 42 };
}
}
Inject it into the page and wrap the slow part in <Await>:
<Await promise={this.stats.load()} fallback={<p>Counting...</p>}>
{(s) => <p>{s.notes} notes, {s.words} words</p>}
</Await>
The page is sent at once with the fallback, and the real content streams into the same response when the promise settles. Measured on the example app:
ttfb=0.004 total=1.506
The first byte left after 4 ms; the stats arrived 1.5 seconds later, in the same request, with no client code to fetch them. If the promise fails or takes longer than amoxi.stream-timeout (10 seconds by default), a short message replaces the fallback and the error is logged. Islands inside a streamed section activate when it arrives.
When you need React
Some UI is best taken from a library: a date picker, a chart, a rich editor. Name the island file Something.react.tsx, start it with the pragma /** @jsxImportSource react */, and install React:
npm install react react-dom
It is used exactly like any island. React is downloaded only by pages that contain a React island, so the rest of the site stays small.
Recap
- Pages are server-rendered JSX: fast, accessible, and they work without JavaScript.
- Islands are the only code the browser runs, loaded when you choose.
- Signals update exactly the DOM nodes that depend on them.
- Navigation between pages feels like a single-page app, for free.
-
<Await>streams slow sections into the same response.
Next
Article 4 covers what happens when data comes back: validating request bodies straight from your TypeScript types, sessions, CSRF protection and caching.
Amoxi is open source under the Apache License 2.0. Code: github.com/karawash-projects/amoxi. Documentation: karawash-projects.github.io/amoxi.
Top comments (0)