Ajo
Server rendering
Render components to HTML with ajo/html and continue on the client.
The same components that run in the browser can render to HTML on a server. ajo/html turns a tree into markup, the browser shows it before any script runs, and render from ajo then takes over the existing DOM instead of rebuilding it.
Render to a string
render(children) from ajo/html returns the markup as a string. Server code often imports it under another name, to keep it apart from the browser’s render.
import { render as toHTML } from 'ajo/html'
import { Journal } from '../src/journal'
const notes = await loadNotes() // your data access
const html = toHTML(<Journal notes={notes} />) // a string of HTML, ready to sendhtml(children, emit) produces the same markup in pieces, calling emit with each chunk in document order, so you can write to a response as the tree is walked.
import { html } from 'ajo/html'
import { Journal, type Note } from '../src/journal'
export function write(res: { write(chunk: string): void }, notes: Note[]) {
res.write('<!DOCTYPE html><html lang="en"><body><div id="root">')
html(<Journal notes={notes} />, chunk => res.write(chunk))
res.write('</div><script type="module" src="/main.js"></script></body></html>')
}Both functions are synchronous. Plain elements and text are emitted as they are reached; a stateful component’s markup is collected and emitted in one piece when it finishes, so an error it catches can replace its partial output. Nothing waits for promises: load data first and pass it in as args.
Stateful components on the server
A stateful component renders exactly once during server rendering:
- Its setup code runs, then the loop up to the first
yield. - The yielded tree is rendered inside the host, with the host’s tag,
attrsandattr:values. - The generator is finished with
return(), so itsfinallyblocks run, and then its signal aborts.
On the server, this is not an element. It only implements the host protocol: it can be iterated for args, it has a signal, and it reads and writes context. next() and return() do nothing, and throw() rethrows its argument. Refs are never called and event handlers are never attached.
Error boundaries work the same way. If rendering the yielded children throws, the error is thrown into the component at its yield; its partial markup is discarded and the fallback its catch block yields is rendered instead. Errors it does not catch go on to the stateful components around it. Apart from finally blocks, code after the first yield never runs on the server.
Guard browser-only code
Setup code runs on the server too. Anything that needs the DOM or other browser-only APIs, such as listeners on document, observers or localStorage, belongs behind a guard, and so does work that only matters in the browser, such as timers. Two checks cover it: typeof document != 'undefined' tells you whether there is a DOM at all, and this.nodeType == 1 tells you whether the host is a real element.
import type { Stateful } from 'ajo'
export const Draft: Stateful = function* () {
let text = ''
if (typeof document != 'undefined' && this.nodeType == 1) {
text = localStorage.getItem('draft') ?? ''
const id = setInterval(() => localStorage.setItem('draft', text), 5000)
this.signal.addEventListener('abort', () => clearInterval(id))
}
while (true) yield (
<textarea set:value={text} set:oninput={e => text = (e.target as HTMLTextAreaElement).value} />
)
}On the server this renders an empty text area; in the browser it restores the saved draft once it takes over. The clove rules apply the same guard to shared logic.
What the HTML contains
Server output follows the same rules as the browser where it can, and is careful with anything that could turn data into markup.
| Input | Output |
|---|---|
| Text and attribute values | Escaped: & < > " ' become numeric character references. |
An attribute set to true | The bare attribute name, as in disabled. |
false, null or undefined | The attribute is left out. |
key, memo, skip, ref and every set: name | Not rendered. Event handlers, set:value and set:innerHTML leave no trace in the HTML. |
| An attribute name with whitespace, quotes, /, >, = or control characters | Dropped. |
A tag name that does not match ^[A-Za-z][\w:.-]*$ | Replaced with defaults.tag from ajo/html. |
Void elements, such as input, img and br | No closing tag; children are ignored. |
| A plain object that was not created by JSX | Rendered as escaped text, such as [object Object], never as an element. |
Children of an element with skip | Rendered. In the browser, Ajo then leaves them as they are. |
Since set: names are not rendered, a form field whose value must appear in the server HTML needs the attribute too: <input value={title} set:value={title} />. For a text area, render the text as its child. Content inserted with set:innerHTML is not in the server HTML either.
The void elements are area, base, br, col, command, embed, hr, img, input, keygen, link, meta, param, source, track and wbr.
Take over in the browser
In the browser, call render from ajo on the element that holds the server HTML, with the same component and the same data. Ajo reconciles the existing DOM rather than replacing it:
- Elements with the same tag are reused, and attributes the JSX doesn’t mention are removed.
- Text is brought up to date with the JSX.
- Each stateful component takes over its server-rendered host and starts its generator, so setup code now runs in the browser and attaches its listeners.
- Keyed lists adopt the server’s list items in order; keys are not part of the HTML.
- Refs are called and
set:properties, including event handlers, are assigned.
import { render } from 'ajo'
import { Journal } from './journal'
const notes = JSON.parse(document.getElementById('notes')!.textContent!)
render(<Journal notes={notes} />, document.getElementById('root')!)The data has to match what the server used, so send it with the page, for example as JSON in a script element. Where the two trees differ, Ajo does not report a mismatch; it corrects the DOM in place, as on any other render. To take over only part of a container, pass the optional child and ref arguments described in Installation.
With Ajo Kit
Ajo Kit does all of this for every route. It runs the route’s loaders on the server, renders the page and its layouts with ajo/html, writes the route data into the document, and renders the same route in the browser over the server HTML. Routing and Loaders show how routes and their data are defined.
- RoutingPages and layouts, rendered on the server and continued in the browser.
- LifecycleSignals and cleanup, which server rendering runs once.
- API referencerender(), html() and defaults from ajo/html.
Source of truth: README.md in cristianfalcone/ajo