Skip to main content

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>`;
}
);

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​