HTMS 💨 Stream Async HTML, Stay SEO-Friendly
npm install fastify-htmsbash
pnpm add fastify fastify-htms
`
---
Prerequisite
Before starting the server, you need at least one HTML file and a module that exports functions used by HTMS placeholders. These functions will be called to progressively fill in the HTML while it streams.
Example setup:
`html
News feed
Loading news…
User profile
Loading profile…
`
`js
// ./public/index.js
export async function loadNews() {
await new Promise((r) => setTimeout(r, 100));
return ;
}
export async function loadProfile() {
await new Promise((r) => setTimeout(r, 200));
return ;
}
`
When you run the server, htms-js will scan the HTML for elements with data-htms attributes, then dynamically import the functions from the matching module (index.js) to resolve and stream the content.
---
Scoped modules
HTMS supports scoped modules, meaning tasks can resolve from different modules depending on context. You can nest modules and HTMS will pick the right scope for each placeholder.
`html
loading task A from 'root-module.js'...
loading task A from 'child-module.js'...
loading task A from 'child-module.js'...
loading task A from 'root-module.js'...
loading task B from 'root-module.js'...
loading task B from 'child-module.js'...
`
This makes it easier to compose and reuse modules without conflicts.
Task value (
data-htms-value)
data-htms-value passes one argument to the task.
When present, the value is parsed as JSON5 and given to the task as its first parameter.
If the attribute is omitted, the task receives undefined.
- Accepted: undefined, null, booleans, numbers, strings, arrays, objects (JSON5: single quotes, unquoted keys, comments, trailing commas).
- Not accepted: functions, arbitrary JS expressions.
- Need multiple pieces of data? Pack them into one object or array.
$3
`html
`
$3
`ts
export async function loadDefaults(value: undefined | null) {}
export async function loadProfile(value: boolean) {}
export async function loadUser(value: number) {}
export async function loadByName(value: string) {}
export async function loadFeed(value: { theme: string; limit: number }) {
// value.theme === 'compact'
// value.limit === 10
}
export async function renderOffer(value: [number, { theme: string }]) {
const [offerId, options] = value;
// offerId === 42
// options.theme === 'compact'
}
`
$3
- Keep it serializable. Only data you could express in JSON5 should go here.
- Prefer objects when the meaning of fields matters: { id, page, sort } is clearer than [id, page, sort].
- Strings must be quoted. Use JSON5 single quotes in HTML to stay readable.
- Validate inside the task. Treat the value as untrusted input.
- One argument by design. If you need several inputs, bundle them: (value) where value is an object/array.
Commit behavior (
data-htms-commit)
Controls how the streamed result is applied to the placeholder. Default: replace.
| Value | Effect | DOM equivalent |
| --------- | --------------------------------------------------- | ---------------------------- |
| replace | Replace the placeholder node (outer) | host.replaceWith(frag) |
| content | Replace the children of the placeholder (inner) | host.replaceChildren(frag) |
| append | Append result as last child | host.append(frag) |
| prepend | Insert result as first child | host.prepend(frag) |
| before | Insert result before the placeholder | host.before(frag) |
| after | Insert result after the placeholder | host.after(frag) |
HTML examples
Assuming the streamed content is:
`html
Loading…
Streamed
Loading…
Streamed
Existing
Existing
Streamed
Existing
Streamed
Existing
Streamed
Streamed
`
Notes
- With append, prepend, before, after, the placeholder stays in the DOM. Remove or restyle it if needed once the chunk is committed.
- With content, you keep the container (useful for accessibility/live regions).
$3
When data-htms-commit="content" is used, HTMS automatically marks the placeholder as a polite live region while it is pending:
- Adds role="status" and aria-busy="true" on the host before the first update.
- On commit, flips aria-busy to false so screen readers announce the final content once.
This gives you accessible announcements out of the box, without extra markup. If you need a different behavior, switch to another commit mode or set your own ARIA attributes on the host.
---
Usage
`ts
import Fastify from 'fastify';
import fastifyHtms from 'fastify-htms';
const app = Fastify();
app.register(fastifyHtms, {
root: './public',
index: 'index.html',
match: '*/.html',
});
app.listen({ port: 3000 });
`
This will:
- Look for .html files under the given root
- Stream them through the HTMS pipeline
- Serve index.html when the path is a directory
- Return 404 if no match is found
To also serve static assets (images, css, js), register @fastify/static alongside this plugin.
---
Options
| Option | Type | Default | Description |
| ---------------- | -------------------------------- | --------------------------- | ----------------------------------------------------------- |
| root | string | | Required. Folder that contains your .html files |
| index | string | 'index.html' | Default file to serve when a directory is requested |
| match | string | '*/.htm?(l)' | Minimatch pattern to filter which files are handled by HTMS |
| environment | 'development' \| 'production' | 'development' | Set the environment |
| compression | boolean | true | Enable response compression |
| encodings | HtmsCompressorEncoding | ['br', 'gzip', 'deflate'] | Enable response compression |
| cacheModule | boolean | true | Enable module caching |
| createResolver | (filePath: string) => Resolver | undefined` | Custom resolver factory for HTMS |