Lifecycle Events
Lifecycle hooks let plugins run code at specific points in the build pipeline. Register a hook with alloy.hook() or alloy.on() to modify content, inject pages, transform data, or observe build events.
// plugins/lazy-images.js
export default function(alloy) {
alloy.hook("onContentTransformed", {}, (page) => {
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
return page;
});
}
Hooks work identically across all plugin tiers (QuickJS, WASM, Node). Payloads are JSON-serializable.
Hook Registration
alloy.hook(eventName, options, handlerFn);
// or equivalently:
alloy.on(eventName, options, handlerFn);
The options object is required. It controls execution order and payload scoping:
alloy.hook("onPageRendered", {
priority: 10, // lower runs first (default 50)
data: ["navigation"], // site.data keys to include
pages: "/blog/**", // page filter (glob)
pageFields: ["frontMatter", "url"] // fields per page
}, fn);
See Hook Scoping for the full scoping API.
All Lifecycle Events
Per-Build Hooks
These fire once per build. Payloads are JSON objects representing build-level state.
onConfig
Fires after config is loaded but before the build starts. The hook receives the full configuration object and must return it. Only fields on the mutable allowlist are applied back — all other fields are silently ignored.
alloy.hook("onConfig", {}, (config) => {
config.build.output = "dist";
config.structure.content = "pages";
return config;
});
Mutable fields:
| Field | Type | Description |
|---|---|---|
build.output |
string | Output directory |
build.clean |
boolean | Clean output before build |
structure.content |
string | Content directory |
structure.layouts |
string | Layouts directory |
structure.assets |
string | Assets directory |
structure.static |
string | Static files directory |
structure.data |
string | Data directory |
passthrough |
array | Passthrough file mappings ([{ from, to }]) |
plugins.workers |
number | Worker pool size |
plugins.timeout |
number | Hook timeout in milliseconds |
Fields not listed above (title, baseURL, language, taxonomies, etc.) are present in the payload for inspection but mutations have no effect.
Return value rules:
- Must return an object. Returning
nullor a non-object produces a build error. - Multiple
onConfighooks chain in priority order — each receives the previous hook’s return value. - A timed-out hook’s mutations are discarded; the next hook receives the pre-timeout value.
Path validation
Directory path fields (build.output, structure.content, structure.layouts, structure.assets, structure.static, structure.data) and passthrough entries are validated before any are applied to the config. If any field fails validation, the entire return value is rejected — no partial mutation.
Rejected values for path fields:
- Absolute paths (
/etc/shadow,C:\Windows) ..traversals that resolve above the project root (../../evil).(current directory — would conflict with the project root)- Empty strings
- On Windows: reserved device names (
NUL,CON) and volume-relative paths
Relative paths with embedded .. segments that resolve within the project are valid and cleaned before use (e.g., subdir/../dist becomes dist).
Passthrough-specific rules:
passthrough[N].fromfollows the same rules as path fields.from: "."is rejected (would copy the entire project root into output).passthrough[N].toallows"."and""— these mean “root of the output directory,” which is a valid destination.
Error messages include the field name and array index for passthrough entries:
onConfig: passthrough[2].from: path "../../secrets" traverses above the project root
onDataFetched
Fires after external data sources are fetched and merged into site data. Plugin can modify or enrich the data.
alloy.hook("onDataFetched", { data: ["team"] }, (data) => {
if (data.team) {
data.teamCount = data.team.length;
data.teamByDepartment = {};
for (const member of data.team) {
const dept = member.department || "unassigned";
if (!data.teamByDepartment[dept]) data.teamByDepartment[dept] = [];
data.teamByDepartment[dept].push(member);
}
}
return data;
});
This is the primary mechanism for adding computed data that templates can access via site.data.*.
onBeforeValidation
Fires before output path conflict detection. The payload contains all computed output paths. Return { addOutputs: { path: source } } to register additional output paths that feed into conflict detection.
alloy.hook("onBeforeValidation", {}, (payload) => {
return {
addOutputs: {
"_redirects": "plugin:netlify-redirects",
"_headers": "plugin:netlify-headers"
}
};
});
| Payload field | Type | Description |
|---|---|---|
outputPaths |
string[] | All computed page output paths |
| Return field | Type | Description |
|---|---|---|
addOutputs |
object | Map of additional output paths to source identifiers |
Unrecognized keys in the return value produce a build error. Each plugin runs independently via RunEachWithTimeout — plugins do not see each other’s additions.
onAfterValidation
Fires after conflict detection passes. The payload includes the validated output paths and the site data cascade. Return { cascade: { ... } } to merge data into siteData for template rendering.
alloy.hook("onAfterValidation", {}, (payload) => {
return {
cascade: {
buildTimestamp: new Date().toISOString(),
pageCount: payload.outputPaths.length
}
};
});
| Payload field | Type | Description |
|---|---|---|
outputPaths |
string[] | Validated output paths (including any added by onBeforeValidation) |
cascade |
object | Current site data cascade |
| Return field | Type | Description |
|---|---|---|
cascade |
object | Merged into siteData — keys overwrite existing values |
Returning outputPaths in the return value has no effect. Unrecognized keys produce a build error. Each plugin runs independently.
Pre-Taxonomy Hook
onPagesReady
Fires once per language batch, after the data cascade is applied but before taxonomy collection. This is the injection point for virtual pages that need to participate in taxonomies.
alloy.hook("onPagesReady", { data: ["elements"], pages: false }, (payload) => {
var elements = payload.siteData.elements || [];
var newPages = [];
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
newPages.push({
path: "demos/" + el.slug + ".md",
url: "/demos/" + el.slug + "/",
frontMatter: {
title: el.name + " Demo",
layout: "demo",
tags: [el.tagName]
},
content: "## " + el.name + "\n\n" + el.description
});
}
return { addPages: newPages };
});
Virtual page fields:
| Field | Required | Description |
|---|---|---|
path |
yes | Source-relative identifier (e.g., demos/button.md) |
url |
yes | Permalink (e.g., /demos/button/) |
frontMatter |
no | Page metadata, including taxonomy terms like tags |
content |
no | Raw markdown content (rendered through the pipeline) |
dependencies |
no | Array of project-root-relative file paths for incremental rebuild tracking |
Virtual pages injected here flow through the full remaining pipeline: taxonomy collection, content rendering, layout resolution, and output writing.
When using pages: false in the options, return { addPages: [...] } to inject pages without round-tripping all existing pages through the plugin bridge.
Virtual page dependencies
During alloy dev, virtual pages are re-rendered on every incremental rebuild by default. Declare dependencies to tell Alloy which source files a virtual page depends on — it will only re-render when one of those files changes.
alloy.hook("onPagesReady", { data: ["elements"], pages: false }, (payload) => {
const elements = payload.siteData.elements || [];
return {
addPages: elements.map(el => ({
path: `demos/${el.slug}.html`,
url: `/demos/${el.slug}/`,
dependencies: [`elements/${el.slug}/demo/index.html`],
frontMatter: { layout: "demo", markdown: false },
content: fs.readFileSync(`elements/${el.slug}/demo/index.html`, "utf-8")
}))
};
});
dependencies value |
Incremental rebuild behavior |
|---|---|
["a.html", "b.css"] |
Re-render only when a listed file appears in the changed files |
[] (empty array) |
Never re-render — no file dependencies to invalidate |
| Omitted | Always re-render on every rebuild (default, safe fallback) |
Paths must be project-root-relative strings. Absolute paths, .. traversals above the project root, and empty strings produce build errors.
On initial builds and for newly added virtual pages, dependencies has no effect — pages always render at least once before dependency filtering applies.
Content Hooks
onContentLoaded
Fires once with the full pages array after content rendering. Modify frontMatter and html on existing pages. Other fields (content, path, url) are present for inspection but mutations are not applied back.
alloy.hook("onContentLoaded", {
pages: true,
pageFields: ["frontMatter", "html", "url"]
}, (pages) => {
pages.forEach(page => {
if (page.frontMatter.draft) {
page.frontMatter.noindex = true;
}
});
return pages;
});
Mutating html:
alloy.hook("onContentLoaded", {
pages: true,
pageFields: ["html", "url"]
}, (pages) => {
pages.forEach(page => {
page.html = `<article>${page.html}</article>`;
});
return pages;
});
Both frontMatter and html can be mutated in the same hook call. Changes to html are applied via SetRenderedBody — the modified HTML replaces the rendered content for that page before layout rendering.
The return array must be the same length and order as the input. Virtual page injection is not supported here – use onPagesReady instead.
onDataCascadeReady
Fires once with the full pages array after the data cascade is resolved. Each entry has the per-page cascade data. Plugin can enrich cascade data.
alloy.hook("onDataCascadeReady", { pages: true }, (pages) => {
pages.forEach(page => {
page.data.generatedAt = new Date().toISOString();
});
return pages;
});
Per-Page Hooks
These fire once per page. They receive page-scoped payloads.
onContentTransformed
Fires after Markdown-to-HTML conversion but before layout rendering. Receives a page-scoped object with html, toc, path, url, and frontMatter. Return values can include html, toc, frontMatter, and addDependencies.
alloy.hook("onContentTransformed", {}, (page) => {
// Add lazy loading to images
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
// Build TOC for non-markdown pages
if (!page.toc || page.toc.length === 0) {
page.toc = extractHeadingsFromHTML(page.html);
}
return page;
});
Declaring dependencies for incremental rebuilds:
alloy.hook("onContentTransformed", {}, (page) => {
const deps = extractImports(page.html);
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
return { ...page, addDependencies: deps };
});
addDependencies is an array of project-root-relative file paths (e.g., ["locales/en.json", "data/nav.yaml"]). During alloy dev, Alloy tracks a reverse index from these paths to pages and rebuilds only affected pages when a dependency changes. See Dependency Tracking below.
onPageRendered
Fires after template rendering produces the final page HTML. Receives a page object with html, frontMatter, url, and path. Only html in the return is applied back – frontMatter, url, and path are read-only context.
alloy.hook("onPageRendered", {}, (page) => {
if (page.frontMatter.layout === "demo") return page;
page.html = page.html.replace(/<h2/g, '<h2 class="styled"');
return page;
});
| Field | Type | Mutable | Description |
|---|---|---|---|
html |
string | yes | Final rendered HTML |
frontMatter |
object | no | Page front matter (read-only context) |
url |
string | no | Page URL |
path |
string | no | Source-relative file path |
Return values can include addDependencies to declare external file dependencies for incremental rebuilds during alloy dev:
alloy.hook("onPageRendered", {}, (page) => {
const result = renderSSR(page.html);
return {
html: result,
addDependencies: [
"elements/rh-card/rh-card.js",
"elements/rh-icon/rh-icon.js",
],
};
});
Pages whose outputs contains only non-HTML formats (e.g., outputs: ["json"]) skip onPageRendered entirely. Those pages route through onFormatRendered instead.
onFormatRendered
Fires once per non-HTML format body after layout rendering. Pages that declare non-HTML entries in their outputs front matter (e.g., "json", "xml") have each format body dispatched through this hook individually.
alloy.hook("onFormatRendered", {}, (payload) => {
if (payload.format === "json") {
// Minify JSON output
return { content: JSON.stringify(JSON.parse(payload.content)) };
}
});
| Field | Type | Description |
|---|---|---|
format |
string | Output format extension ("json", "xml", etc.) |
content |
string | Rendered format body |
url |
string | Page URL |
path |
string | Source-relative file path |
frontMatter |
object | Page front matter (read-only context) |
Return value:
| Return | Effect |
|---|---|
{ content: "..." } |
Replaces the format body in output |
null / undefined |
Keeps the original content |
Object without content key |
Keeps the original content |
Only the content field is applied back. The format, url, path, and frontMatter fields are read-only context for conditional processing.
Iteration order: When a page declares multiple non-HTML formats, onFormatRendered fires in the order formats appear in the page’s outputs array, not in arbitrary map iteration order. Front matter is converted once per page and reused across all format invocations for that page.
Relationship to onPageRendered:
onPageRenderedfires only for pages whoseoutputsincludes"html"(or defaults to["html"]). Pages with only non-HTML outputs skiponPageRenderedentirely.- A page with
outputs: ["json"]routes throughonFormatRenderedonly. - A page with
outputs: ["html", "json"]fires both hooks independently –onPageRenderedfor the HTML body, thenonFormatRenderedfor each non-HTML format.
Dependency Tracking
Both onContentTransformed and onPageRendered support addDependencies in their return values. This drives targeted incremental rebuilds during alloy dev:
- A plugin returns
addDependencies: ["path/to/file.js"]from a per-page hook. - Alloy records each path in a reverse index keyed by page.
- When a watched file changes,
onFileChangedcan returninvalidateByDependencywith the changed paths. Only pages whose reverse-index entries match are rebuilt.
Dependencies accumulate per page per build. If a plugin stops returning a path (e.g., a component tag is removed from the page template), that dependency drops from the index on the next rebuild.
Paths are normalized with filepath.Clean – ./data.json and data/../data.json both resolve to data.json. Non-array addDependencies values produce a warning and are ignored.
Per-Asset Hook
onAssetProcess
Fires once per file in the assets directory during asset copy. Each invocation receives a single file’s path and content. Multiple onAssetProcess hooks chain — each receives the content returned by the previous hook.
alloy.hook("onAssetProcess", {}, (asset) => {
if (asset.path.endsWith('.css')) {
return { content: minifyCSS(asset.content) };
}
// Return null or omit content key to keep the original
});
| Field | Type | Description |
|---|---|---|
path |
string | File path relative to the assets directory (forward slashes, e.g., css/main.css) |
content |
string | Raw file content |
Return value:
| Return | Effect |
|---|---|
{ content: "..." } |
Replaces the file content in output |
null / undefined |
Keeps the original content |
Object without content key |
Keeps the original content |
The path key in the return value is ignored — the file is always written to its original relative path in the output directory. A hook error stops the build.
Dev Server Hooks
onFileChanged
Fires once per file-watch batch during alloy dev. The payload is an array of change events, not a single file path.
alloy.hook("onFileChanged", {}, (events) => {
for (const event of events) {
console.log(`${event.Path} changed (removed: ${event.IsRemove})`);
}
});
Payload fields:
| Field | Type | Description |
|---|---|---|
Path |
string | File path relative to project root |
ChangeType |
number | Change category (1–8: content, layout, data, asset, static, component, passthrough, plugin) |
IsRemove |
boolean | true when the file was deleted |
Return value:
Return an object to control how Alloy responds to the file change. Omitting the return value (or returning undefined) preserves the default behavior.
| Return field | Type | Description |
|---|---|---|
invalidateByDependency |
string[] | File paths to match against the dependency reverse index – only pages that declared these as dependencies are rebuilt |
restart |
boolean | Restart Node bridge subprocesses before the rebuild (clears ESM module cache) |
alloy.hook("onFileChanged", {}, (events) => {
const changed = events
.filter(ev => ev.Path.startsWith("elements/") && ev.Path.endsWith(".js"))
.map(ev => ev.Path);
if (changed.length > 0) {
return { invalidateByDependency: changed, restart: true };
}
});
invalidateByDependency paths are matched against the reverse index built from addDependencies declarations in onContentTransformed and onPageRendered. Only pages that declared a matching dependency are rebuilt. If a path has no reverse-index entries, it has no effect.
restart must be a boolean. Non-boolean values are dropped with a warning.
Read-Only Hooks
Return values are ignored. Plugins observe but cannot modify.
onBuildComplete
Fires after the build finishes. The payload contains build stats and any errors that occurred.
alloy.hook("onBuildComplete", {}, (result) => {
console.log(`Built ${result.pageCount} pages in ${result.duration}`);
if (result.errors.length > 0) {
console.warn(`${result.errors.length} error(s) during build`);
}
});
| Field | Type | Description |
|---|---|---|
pageCount |
number | Total pages built |
duration |
string | Build time as a formatted string (e.g., "53ms") |
errors |
string[] | Build errors (empty array when the build succeeds) |
outputDir |
string | Output directory path |
Plugins that need page output content should read from the output directory on disk — Alloy does not pipe rendered HTML to plugins over IPC.
onDevServerStart
Fires when the dev server starts. The payload is the full site configuration object — there is no url field with the server address.
alloy.hook("onDevServerStart", {}, (config) => {
console.log(`Dev server started for "${config.Title}"`);
});
Hook Execution Order
Hooks execute by priority (lower runs first), then by alphabetical plugin filename within the same priority. Each hook receives the output of the previous one – they chain, not race.
// Plugin A: runs first
alloy.hook("onPageRendered", { priority: 10 }, transformFn);
// Plugin B: runs second (default priority 50)
alloy.hook("onPageRendered", {}, analyticsFn);
// Plugin C: runs last
alloy.hook("onPageRendered", { priority: 100 }, ssrFn);
Hook Timeout
Each hook call is subject to the configured timeout (default 5 seconds). A timed-out hook produces a warning, its modifications are discarded, and the build continues with the pre-hook payload.
plugins:
timeout: 5000
Related
- Hook Scoping – control what data hooks receive
- Plugin System – plugin tiers and registration
- QuickJS Plugins – embedded JS plugins
- Node Plugins – subprocess plugins with npm access