Hooks
Hooks are how plugins observe and mutate the publish pipeline. There are two kinds:
- Filters mutate a value. Each handler receives the current value, returns a new (or unchanged) value. Composable in priority order.
- Actions are side effects. Each handler runs; nothing is returned; one handler can't short-circuit another.
This page is the practical "how to subscribe" guide. For the exhaustive list of every hook the core fires, see the Hooks reference.
Filters
api.addFilter<string>(
"page.head.extra", // hook name
(current, baseLayoutProps) => { // handler
return current + "<meta name='generator' content='My Plugin' />";
},
10 // priority (lower runs first; default 10)
);
The handler can be sync or async. The publisher awaits each handler in priority order, so async work serialises:
api.addFilter<string>("page.head.extra", async (current) => {
const inserted = await fetchExtraTags();
return current + inserted;
});
In practice, page.head.extra and page.body.end are called synchronously by the publisher (via applyFiltersSync) so async handlers don't help — they'd block the render. Use sync logic for those two; the rest can be async.
Common filters
post.html.body
Transform the rendered post body HTML.
api.addFilter<string>(
"post.html.body",
(html, post, ctx) => {
return html.replace(/{{author}}/g, post.authorId);
},
10
);
Receives (html: string, post: Post, ctx: PublishContext). Use for marker → real-HTML transforms (block plugins, embed plugins).
page.head.extra
Inject <head> markup. Synchronous.
api.addFilter<string>(
"page.head.extra",
(current, props) => {
const tag = `<meta name="theme-color" content="#3b82f6" />`;
return current + tag;
}
);
Receives (current: string, baseProps: BaseLayoutProps). Read post / page / category from baseProps for per-page customisation.
page.body.end
Inject markup just before </body>. Synchronous.
api.addFilter<string>(
"page.body.end",
(current, props) => {
return current + `<script defer src="/my-runtime.js"></script>`;
}
);
menu.json.resolved
Mutate the resolved menu structure before it's uploaded as /menu.json.
api.addFilter<MenuJson>(
"menu.json.resolved",
(menu, ctx) => {
return {
...menu,
footer: [
...menu.footer,
{ label: "RSS", href: "/rss.xml", target: "_blank" },
],
};
}
);
Receives (menu: MenuJson, ctx: { settings, posts, pages, terms }). Used by flexweg-rss to inject feed URLs into the footer.
post.template.props
Mutate the props passed to a theme template before render.
api.addFilter<SingleTemplateProps>(
"post.template.props",
(props, post, ctx) => {
return {
...props,
relatedPosts: findRelated(post, ctx.posts).slice(0, 5),
};
}
);
Used by themes / plugins to enrich template props with computed data.
Filter priority
Lower numbers run first. Default is 10.
api.addFilter("post.html.body", transformA); // priority 10
api.addFilter("post.html.body", transformB, 5); // priority 5 — runs first
api.addFilter("post.html.body", transformC, 20); // priority 20 — runs last
Use lower priorities (5) when you need to run before the default theme's filters. Use higher priorities (20+) when you need the value all other plugins have produced.
Actions
api.addAction(
"publish.complete",
async (post, ctx) => {
await regenerateMyFiles(post, ctx);
}
);
Returns nothing. All registered handlers run for every fire — there's no ordering guarantee beyond priority, and one handler's exception doesn't prevent the others from running.
Common actions
publish.complete
Fires after a successful publish.
api.addAction("publish.complete", async (postRaw, ctxRaw) => {
const post = postRaw as Post;
const ctx = ctxRaw as PublishContext;
// ctx.posts has been patched with the new state of the post
await regenerateSomeFiles(post, ctx);
});
Used by all the file-generating plugins: sitemaps, RSS, archives, search.
post.unpublished
Fires after a successful unpublish.
api.addAction("post.unpublished", async (post, ctx) => {
await regenerateAffectedFiles(post, ctx);
});
post.deleted
Fires after a successful delete.
api.addAction("post.deleted", async (post, ctx) => {
await cleanUpAfter(post, ctx);
});
The three lifecycle actions all receive (post, ctx) where ctx is the patched publish context — ctx.posts and ctx.pages already reflect the new state of the post.
Why all three?
All three lifecycle actions typically fire the same handler (or call the same regenerator). Why have them separate then? Because the handler can read post.status to decide what to do — a draft → online transition is different from an online → draft transition.
In practice, most plugins handle them identically:
async function regenerate(post: Post, ctx: PublishContext) { /* … */ }
api.addAction("publish.complete", regenerate);
api.addAction("post.unpublished", regenerate);
api.addAction("post.deleted", regenerate);
Reading the publish context
The (post, ctx) payload of lifecycle actions exposes everything the publisher loaded:
interface PublishContext {
posts: Post[]; // all posts (patched with current transition)
pages: Post[]; // all pages
terms: Term[]; // all categories + tags
media: Map<string, Media>; // all media, keyed by id
settings: SiteSettings;
authors: Map<string, AuthorView>;
// …
}
So your action handler doesn't need to fetch from Firestore. The publisher already loaded everything; reading ctx.posts is free.
Reading per-page context in head/body filters
page.head.extra and page.body.end filters receive (current: string, baseProps: BaseLayoutProps) where baseProps.post (or baseProps.term, baseProps.author) tells you what page is being rendered:
api.addFilter<string>("page.head.extra", (current, props) => {
if (props.post) {
// We're rendering a single post or page
return current + buildOgMetaForPost(props.post);
}
return current; // home / category / 404 — skip
});
baseProps.site.settings.pluginConfigs.<id> is the plugin's live config.
When NOT to use a hook
Hooks fire during publishing. Don't use them for:
- Editor-time work — block manifests are the right place; hooks fire only at publish time.
- Synchronous Firestore writes — actions can be async, but slow handlers slow every publish. If you need to write a lot, consider deferring (queue + cron / on-demand button).
- Reading from external services — same problem. Cache or pre-fetch into the publish context.
Continue
- Hooks reference — every hook the core fires
- Plugin manifest
- Plugin blocks — for editor-time work