DEV Community

Ahmad Karawash
Ahmad Karawash

Posted on

Amoxi: Fast pages with very little JavaScript

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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, not className, and for, not htmlFor. It is HTML.
  • Values are escaped automatically, so text from users cannot inject markup.
  • A page can be async, return HttpResponse.redirect('/login'), or use withStatus(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>
  );
}
Enter fullscreen mode Exit fullscreen mode

The building blocks:

  • signal('') returns a getter and a setter. Call query() 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.
  • For renders a list and, thanks to key, 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" />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)