@domstack/fastify
A Fastify plugin that brings the domstack page/layout/variable-cascade system into a Fastify server. Pages have access to the full request context alongside the domstack variable cascade — enabling dynamic, server-rendered hypermedia apps using the same src directory structure you already know.
npm install @domstack/fastify
Usage
import Fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import domstackFastify from '@domstack/fastify'
const fastify = Fastify()
// Register @fastify/static first, pointing at your dest directory.
// @domstack/fastify does not register it for you.
await fastify.register(fastifyStatic, {
root: new URL('public', import.meta.url).pathname,
prefix: '/',
index: false,
decorateReply: false,
})
await fastify.register(domstackFastify, {
src: 'src', // domstack source directory
dest: 'public' // output directory for client bundles and static assets
})
await fastify.listen({ port: 3000 })On startup the plugin:
- Discovers pages, layouts, and global files in
src - Runs esbuild to bundle client JS/CSS into
dest - Copies static assets from
srctodest - Pre-renders
page.js,page.md, andpage.htmlfiles intodest/as static files - Spawns a render worker (isolated ESM module cache) for page rendering
- Registers a
GETroute for eachpage.route.jsfile and loose markdown page
Options
| Option | Type | Default | Description |
|---|---|---|---|
src |
string |
— | Path to the domstack source directory (required) |
dest |
string |
— | Output directory for client bundles and static assets (required) |
watch |
boolean |
false |
Enable watch mode: rebuild on file changes, respawn render worker for ESM cache busting |
prefix |
string |
'' |
URL prefix for all page routes (e.g. '/app'). Static assets always serve from /. |
ignore |
string[] |
[] |
Glob patterns to exclude during page discovery |
buildDrafts |
boolean |
false |
Include draft pages (.draft.{js,ts,md,html}) |
target |
string[] |
esbuild default | esbuild target environments (e.g. ['es2020']) |
Pages
@domstack/fastify uses two distinct page filename conventions to separate static pre-rendering from dynamic server-rendered routes:
| Filename | Behavior |
|---|---|
page.js / page.ts |
Static — pre-rendered at startup, written to dest/, served as a static file |
page.route.js / page.route.ts |
Dynamic — registered as a Fastify GET route, rendered per-request with request/reply context |
page.md, page.html, README.md |
Static — always pre-rendered as static files |
Dynamic pages (page.route.js)
page.route.js files become Fastify GET routes. The default export receives the standard domstack params plus request and reply:
// src/bookmarks/page.route.js
export const vars = { title: 'Bookmarks', layout: 'root' }
export default async function ({ vars, request }) {
const user = request.user // set by a Fastify hook
const bookmarks = await fetchBookmarks(user.id)
return `<ul>${bookmarks.map(b => `<li><a href="${b.url}">${b.title}</a></li>`).join('')}</ul>`
}The request object contains:
| Property | Type | Description |
|---|---|---|
method |
string |
HTTP method (GET, POST, …) |
url |
string |
Full request URL |
params |
Record<string, string> |
Route params (e.g. { id: '123' }) |
query |
Record<string, string> |
Parsed query string |
headers |
Record<string, string | string[]> |
Request headers |
Static pages (page.js)
page.js files are always pre-rendered at startup and written to dest/. No Fastify route is registered for them — @fastify/static serves them directly.
// src/about/page.js
export const vars = { title: 'About' }
export default async function ({ vars }) {
return `<h1>${vars.siteName}</h1><p>This is rendered once at startup.</p>`
}In watch mode, static pages are re-rendered whenever a page or global file changes.
MD and HTML pages
Markdown and HTML pages work the same as in @domstack/static — variables are available via Handlebars template blocks. Directory-index page.md and page.html files are always pre-rendered as static files. The request context is not available in markdown or HTML pages.
Loose markdown pages
Any .md file that isn't named page.md or README.md is a loose markdown page. It gets a Fastify route at its filename stem (without the .html extension):
src/notes.md → GET /notes
src/blog/intro.md → GET /blog/intro
Route mapping
| Page file | Behavior |
|---|---|
page.route.js (root) |
GET / |
bookmarks/page.route.js |
GET /bookmarks |
blog/_id/page.route.js |
GET /blog/:id |
about/page.js |
Static file: dest/about/index.html |
notes.md (loose) |
GET /notes |
blog/intro.md (loose) |
GET /blog/intro |
Segments starting with _ become Fastify route params (:paramName). The resulting params are available on request.params in the page function.
Variable cascade
Variables cascade from global to page level, just like in @domstack/static:
global.vars.js → page.vars.js → page frontmatter / page.vars export
// src/global.vars.js
export default {
siteName: 'My Site',
lang: 'en',
}// src/blog/page.route.js
export const vars = {
title: 'Blog', // overrides nothing; adds `title`
layout: 'blog', // selects the "blog" layout
}
export default async function ({ vars, request }) {
return `<h1>${vars.siteName}</h1>` // siteName comes from global.vars.js
}Per-page route options
page.route.js files can export a routeOptions function to customize the Fastify route options object for that specific page. This lets you attach hooks, add config values, apply constraints, or do anything else Fastify's RouteShorthandOptions supports — scoped to one page.
// src/admin/page.route.js
/** @param {import('@domstack/fastify').RouteOptionsHook} options */
export function routeOptions (options) {
return {
...options,
config: { auth: true, role: 'admin' },
constraints: { host: 'admin.example.com' },
}
}
export default function ({ vars }) {
return `<h1>Admin</h1>`
}The function receives the base options object (which already contains schema if you exported one) and must return the final options. It can be async.
// src/hooks/page.route.js — add a response hook to one page
export function routeOptions (options) {
return {
...options,
onSend (req, reply, payload, done) {
reply.header('cache-control', 'no-store')
done(null, payload)
},
}
}The following fields are managed by @domstack/fastify and should not be overridden in routeOptions:
| Field | Reason |
|---|---|
url |
Set from the directory structure |
method |
Always GET — pages are read-only |
handler |
Always the domstack render handler |
Everything else in RouteShorthandOptions is available: schema, config, constraints, onRequest, preHandler, onSend, preSerialization, logLevel, exposeHeadRoute, errorHandler, etc.
Note:
routeOptionsis read at startup during route registration. Changes to it require a server restart (same asschema).
Layouts
Layouts work the same as in @domstack/static. The default layout is the built-in root layout. Custom layouts live anywhere in src and are named *.layout.{js,ts}.
// src/layouts/blog.layout.js
export default function ({ vars, scripts, styles, children }) {
return `<!DOCTYPE html>
<html lang="${vars.lang}">
<head>
<title>${vars.title}</title>
${styles}
</head>
<body>
<nav>...</nav>
${children}
${scripts}
</body>
</html>`
}Templates
Files named *.template.js (or .ts) generate arbitrary output files at startup. They're ideal for RSS feeds, sitemaps, robots.txt, or any file that needs access to the full page list.
// src/sitemap.template.js
export default async function ({ vars, pages, template }) {
const urls = pages.map(p => ` <url><loc>/${p.pageInfo.path}</loc></url>`).join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
}The template function receives { vars, pages, template } and can return:
| Return type | Description |
|---|---|
string |
Written to dest/ using the template's filename stem (e.g. sitemap.template.js → dest/sitemap) |
{ outputName, content } |
Written to dest/ with a custom filename |
[{ outputName, content }, …] |
Multiple output files |
AsyncIterable<{ outputName, content }> |
Streamed output files (for large sets) |
Template output is written to dest/ at startup, then served as static files by @fastify/static. In watch mode, templates are re-run whenever the template file or any page/global data file changes.
Global files
| File | Description |
|---|---|
global.vars.js |
Site-wide variables available to all pages and layouts |
global.data.js |
Derive and aggregate data from all pages before rendering |
global.css |
Global stylesheet loaded on every page |
global.client.js |
Global client script loaded on every page |
esbuild.settings.js |
Customize esbuild bundler settings |
markdown-it.settings.js |
Customize the markdown-it renderer |
Watch mode
In development, enable watch mode to rebuild on file changes:
await fastify.register(domstackFastify, {
src: 'src',
dest: 'public',
watch: true,
})Watch mode uses chokidar to detect changes and applies the minimal rebuild needed:
| Change | Action |
|---|---|
global.vars.*, esbuild.settings.* |
Full rebuild (re-identify pages, restart esbuild, respawn render worker, re-render static pages, rebuild templates) |
global.data.*, page files, layout files |
Page rebuild (respawn render worker + re-render static pages + rebuild templates) |
*.template.* files |
Template/static rebuild (re-render static pages + rebuild templates, no render worker respawn) |
| Client bundles, stylesheets | esbuild handles rebundling automatically |
Rendering uses a worker thread with an isolated ESM module cache. When a page file changes, the worker is terminated and a fresh one is spawned — so your updated modules are always re-imported without restarting the Fastify process.
License
MIT