Lifecycle Events
Lifecycle hooks let plugins run code at specific points in the build. Register one with alloy.hook() to modify content, inject pages, transform data, or observe the build.
// plugins/lazy-images.js
export default function(alloy) {
alloy.hook("onContentTransformed", {}, (page) => {
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
return page;
});
}
That is the whole shape of a hook: pick an event, receive a payload, return it modified.
Registering a Hook
alloy.hook(eventName, options, handlerFn);
// alloy.on() is an alias for the same thing
alloy.on(eventName, options, handlerFn);
The options object is required, even when empty — pass {}. It controls execution order and how much data your hook receives:
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);
Everything you ask for is serialized and handed to your plugin, so scoping is the main lever on plugin performance. See Hook Scoping for the full API.
When Hooks Fire
Hooks fire in a fixed order during a build. Use this to pick the right one — the earlier a hook runs, the less is decided, and the more you can still influence.
| Order | Hook | Fires |
|---|---|---|
| 1 | onConfig |
Config loaded, before anything is read from disk |
| 2 | onDataFetched |
External data sources fetched and merged |
| 3 | onPagesReady |
Pages discovered, before taxonomy collection — the virtual page injection point |
| 4 | onBeforeValidation |
Output paths computed, before conflict detection |
| 5 | onAfterValidation |
Conflict detection passed |
| 6 | onContentLoaded |
All pages loaded, with rendered content available |
| 7 | onDataCascadeReady |
Per-page data cascade resolved |
| 8 | onContentTransformed |
Per page — Markdown converted to HTML, before layout |
| 9 | onPageRendered |
Per page — layout applied, final HTML ready |
| 10 | onFormatRendered |
Per non-HTML output format (json, xml, …) |
| 11 | onAssetProcess |
Per file in the assets directory |
| 12 | onBuildComplete |
Build finished |
Two hooks fire only under alloy dev: onDevServerStart when the server boots, and onFileChanged on every file-watch batch.
Choosing a Hook
| You want to… | Use |
|---|---|
| Change config before the build reads it | onConfig |
Add computed values to site.data |
onDataFetched |
| Generate pages from data | onPagesReady |
| Register extra output files | onBeforeValidation |
| Add build-wide values for templates | onAfterValidation |
| Modify front matter across all pages at once | onContentLoaded |
| Rewrite a page’s HTML before its layout is applied | onContentTransformed |
| Rewrite a page’s final HTML | onPageRendered |
| Post-process JSON/XML output | onFormatRendered |
| Minify or transform CSS/JS assets | onAssetProcess |
| Report stats or write a manifest | onBuildComplete |
When two hooks could work, prefer the later one for HTML changes — onPageRendered sees the finished page — and the earlier one for anything that must influence downstream stages.
Rules That Apply to Every Hook
Execution order
Hooks run by priority (lower first, default 50). Within the same priority, order is decided by plugin tier first, then plugin name:
- Priority — lower runs first
- Tier — in-process plugins (QuickJS, WASM) before Node subprocess plugins
- Plugin name — alphabetical within a tier
The tier step matters when you mix plugin types: a QuickJS plugin named zebra.js runs before a Node plugin named alpha.js at the same priority. If you need a specific order across tiers, set priority explicitly rather than relying on filenames.
alloy.hook("onPageRendered", { priority: 10 }, runsFirst);
alloy.hook("onPageRendered", {}, runsSecond); // default 50
alloy.hook("onPageRendered", { priority: 100 }, runsLast);
Chained vs. independent hooks
Most hooks chain: each plugin receives the previous plugin’s return value, so changes accumulate.
Some hooks run each plugin independently against the original payload — plugins do not see each other’s returns, and results are collected separately:
| Dispatch | Hooks |
|---|---|
| Chained | onConfig, onDataFetched, onContentLoaded, onDataCascadeReady, onContentTransformed, onAssetProcess, onBuildComplete, onFileChanged, onDevServerStart |
| Independent | onPagesReady, onBeforeValidation, onAfterValidation, onFormatRendered |
| Per page, distributed | onPageRendered |
onPageRendered is dispatched one page at a time and spread across worker processes for speed. See Node Plugins for what that means for plugin state.
Timeouts
Each hook call is subject to the configured timeout (default 5000 milliseconds). A timed-out hook produces a warning, its modifications are discarded, and the build continues with the pre-hook payload.
plugins:
timeout: 5000
Payload differences by plugin tier
QuickJS and Node plugins receive hook payloads directly. WASM plugins receive an envelope — { "event": "<hookName>", "payload": { … } } — because a single hook export multiplexes every event. Return either the payload or the whole envelope; Alloy unwraps it. See WASM Plugins for the module-side contract.
Hook Reference
onConfig
Fires after config is loaded but before the build starts. 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 and passthrough entries are validated before any are applied. 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:
onConfig: passthrough[2].from: path "../../secrets" traverses above the project root
onDataFetched
Fires after external data sources are fetched and merged into site data. Modify or enrich the data.
alloy.hook("onDataFetched", { data: ["team"] }, (data) => {
if (data.team) {
data.teamCount = data.team.length;
}
return data;
});
This is the primary mechanism for adding computed data that templates access via site.data.*.
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) => {
const elements = payload.siteData.elements || [];
return {
addPages: elements.map(el => ({
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}`
}))
};
});
Payload fields are pages and siteData.
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 | Project-root-relative file paths for incremental rebuild tracking |
Virtual pages flow through the full remaining pipeline: taxonomy collection, content rendering, layout resolution, and output writing.
Using pages: false with { addPages: [...] } injects 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 limit that to changes in specific files:
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.
onBeforeValidation
Fires before output path conflict detection. 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.
onAfterValidation
Fires after conflict detection passes. 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 has no effect. Unrecognized keys produce a build error.
onContentLoaded
Fires once with the full pages array. 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;
}
page.html = `<article>${page.html}</article>`;
});
return pages;
});
Changes to html are applied via SetRenderedBody — the modified HTML replaces the rendered content 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 path and data.
alloy.hook("onDataCascadeReady", { pages: true }, (pages) => {
pages.forEach(page => {
page.data.generatedAt = new Date().toISOString();
});
return pages;
});
onContentTransformed
Fires per page, after Markdown-to-HTML conversion but before layout rendering.
alloy.hook("onContentTransformed", {}, (page) => {
page.html = page.html.replace(/<img /g, '<img loading="lazy" ');
return page;
});
| Field | Type | Description |
|---|---|---|
html |
string | Rendered content HTML |
frontMatter |
object | Page front matter |
path |
string | Source-relative file path |
url |
string | Page URL |
toc |
array | Heading structure — omitted entirely for pages with no headings |
Because toc is absent rather than empty on heading-less pages, guard before reading it:
if (!page.toc || page.toc.length === 0) {
page.toc = extractHeadingsFromHTML(page.html);
}
Return values can include html, toc, frontMatter, and addDependencies.
onPageRendered
Fires per page, after template rendering produces the final HTML. Only html in the return is applied back.
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 for incremental rebuilds:
alloy.hook("onPageRendered", {}, (page) => {
return {
html: renderSSR(page.html),
addDependencies: ["elements/rh-card/rh-card.js"],
};
});
Pages whose outputs contains only non-HTML formats skip onPageRendered entirely and route through onFormatRendered instead.
onFormatRendered
Fires once per non-HTML format body after layout rendering. Pages declaring non-HTML entries in outputs (e.g., "json", "xml") have each format body dispatched individually.
alloy.hook("onFormatRendered", {}, (payload) => {
if (payload.format === "json") {
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 content is applied back. Formats fire in the order they appear in the page’s outputs array.
Relationship to onPageRendered:
onPageRenderedfires only for pages whoseoutputsincludes"html"(or defaults to it).outputs: ["json"]routes throughonFormatRenderedonly.outputs: ["html", "json"]fires both hooks independently.
onAssetProcess
Fires once per file in the assets directory during asset copy. Multiple onAssetProcess hooks chain — each receives the content returned by the previous.
alloy.hook("onAssetProcess", {}, (asset) => {
if (asset.path.endsWith('.css')) {
return { content: minifyCSS(asset.content) };
}
});
| Field | Type | Description |
|---|---|---|
path |
string | File path relative to the assets directory (forward slashes) |
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. A hook error stops the build.
onFileChanged
Fires once per file-watch batch during alloy dev. The payload is an array of change events, not a single path.
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 };
}
});
Payload fields (note the capitalized keys):
| 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 field | Type | Description |
|---|---|---|
invalidateByDependency |
string[] | File paths to match against the dependency reverse index |
restart |
boolean | Restart Node bridge subprocesses before the rebuild |
restart must be a boolean. Non-boolean values are dropped with a warning.
onBuildComplete
Fires after the build finishes. Return values are ignored.
alloy.hook("onBuildComplete", {}, (result) => {
console.log(`Built ${result.pageCount} pages in ${result.duration}`);
});
| 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 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. Return values are ignored. The payload is the full site configuration object — there is no field with the server address.
alloy.hook("onDevServerStart", {}, (config) => {
console.log(`Dev server started for "${config.title}"`);
});
Dependency Tracking
Both onContentTransformed and onPageRendered accept addDependencies in their return values, which 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,
onFileChangedreturnsinvalidateByDependencywith 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, 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.
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