stnd.buildSTANDARD MANUAL2026-09-20

Hooks & Extension Points

How modules render into shared zones and call into each other, without ever importing one another. Plus every hook zone the framework itself ships with.

Hooks & Extension Points

Modules don’t import each other. They publish to a named hook, and
whatever’s listening mounts there or gets called there — neither side has
to know the other exists. A hook name is just a shared contract between
producers and consumers, not a reserved keyword: stnd:base,
launcher:action, note:render, or one you make up for your own app’s
modules to coordinate through. The one exception is the astro:* prefix,
reserved for Astro’s own build lifecycle.

Every entry under a module’s hooks: key is classified automatically: an
.astro/.svelte/.md path (or { ui: "..." }) becomes a UI zone
contribution
; a .js/.ts path (or { action: "..." }) becomes a
logic hook — unless the hook name starts with launcher: or ends
with :action, which always forces UI/action classification regardless
of file type (this is what lets client code read launcher:action/
graft:action entries as callable functions via extensions[zone], not
just render them as components). One hooks: block can mix both freely.

A hook name must classify the same way everywhere it’s used — two modules
registering the same name with different entry kinds is a build-time
fatal error (hook-kind-mismatch),
not a silent split. The two kinds compile into two disconnected virtual
modules with different consumers — a hook that’s silently half-UI,
half-logic means each consumer only ever sees its own half.

Render into a UI zone

hooks: {
  "stnd:base":   ["./components/Toast.astro"],
  "stnd:client": [{ ui: "./Drawer.svelte", meta: { "client:load": true } }],
}

Zones are rendered with <Hook zone="stnd:base" /> anywhere in your
layouts. Multiple modules can contribute to the same zone — their
components are rendered in load order, all of them, every time. There’s
no “pick one”; for that, use a logic hook instead and render whatever it
returns yourself.

Call into every module at once — runHook

Fans a call out to every module registered under that name and collects
their results into an array. Use it when you want independent
contributions gathered up (“does anyone have an opinion on this”).

// modules/blog/index.module.js
hooks: {
  "astro:build:done": [{ action: "./hooks/generate-feed.js" }],
}
// consuming the hook
import { runHook } from "virtual:stnd/hooks";
const results = await runHook("astro:build:done", buildContext);

Thread a value through every module — runPipeline

Threads one value through every registered handler in sequence. Each
handler receives the current value and returns the next one — explicitly,
every time; there’s no implicit “return nothing to pass through
unchanged.” A handler that wants to leave the value untouched returns it
as-is. This is deliberate: a handler that forgets to return should fail
loudly (whatever reads the result next chokes on undefined) rather than
silently becoming a no-op.

Use it when a route wants to say “here’s what I’m about to render — does
any module want to override it?” without knowing in advance which module,
if any, will act:

// apps/stnd.gd/modules/password-gate/index.module.js — a real, live example
hooks: {
  "note:render": [{ action: "./server/note-gate.ts", server: true }],
}
// note-gate.ts
export default async function resolveNoteGate(descriptor, ctx) {
  const isLocked = Boolean(ctx.note?.isPasswordProtected && !ctx.isOwner);
  if (!isLocked) return descriptor; // pass through unchanged
  return { ...descriptor, override: true, Component: PasswordGate, props: {/* ... */} };
}
---
// the consuming route — never imports password-gate by name
import { runPipeline } from "virtual:stnd/hooks";
const result = await runPipeline("note:render", { override: false, Component: null, props: {} }, { note, isOwner });
const GateComponent = result.Component; // dynamic Astro tags need a capitalized local const
---
{ result.override && GateComponent ? <GateComponent {...result.props} /> : <RealContent /> }

A second module (paywall, region-lock, whatever) can register under the
same note:render name later without the route ever changing — that’s
the whole point of a pipeline hook over a direct import. Since
runPipeline has no built-in “first claim wins” arbitration, a
well-behaved handler should check whether a prior one already claimed the
thing it cares about (if (descriptor.override) return descriptor;)
before overwriting it — otherwise the last-loaded module silently wins
with no signal a conflict even happened.

Keep server-only logic out of the client bundle

Mark any handler that touches server-only data (DB calls, secrets, API
keys) with server: true — virtual:stnd/hooks resolves to different
content for SSR vs. the client bundle, and handlers without server: true ship into client JS if anything in your app imports
runHook/runPipeline client-side (the launcher does, for
launcher:action). This only works for logic hooks: setting server: true on a launcher:/:action-suffixed entry is a build-time fatal
error rather than a silent no-op
(hook-server-flag-ignored),
because that pool compiles into virtual:stnd/components, which has no
SSR/client split at all.

Both runHook and runPipeline isolate handler failures: a throwing
handler is logged (with its module ID) and skipped, not left to abort
every other module’s contribution to the same hook name. One broken
module’s handler can’t blank out the whole hook or 500 a page it wasn’t
even trying to act on.

Native hooks

These are the hook names the framework’s own gold standard modules ship
with — the ones every Standard site has access to unless the module is
excluded via moduleExclude. Everything else (note:render, your own
app’s hooks) is a convention your own modules invent as they go.

Hook Kind Who registers it What lands there
stnd:base UI zone @stnd/modules/toast, @stnd/modules/confetti Static Astro components — no hydration. Rendered via <Hook zone="stnd:base" /> in StndInit.astro.
stnd:client UI zone @stnd/modules/launcher, @stnd/modules/lab Hydrated Svelte components — client:load islands. Rendered via <Hook zone="stnd:client" hydrated /> in StndInit.astro.
launcher:view UI/action @stnd/modules/stripe (and any app module) A full Svelte view registered into the command palette, triggered by a ::trigger string — see @stnd/launcher.
launcher:action UI/action @stnd/account (and any app module) A palette command’s metadata + handler, run via runHook("launcher:action", ...) when the launcher fans out a search or command.

Add a view to the launcher

The most common reason to reach for a hook — registering a new palette
view:

  1. Create the .svelte file (in packages/account/views/ or your own module’s views/), exporting a component with let { close, back, setSize } = $props().
  2. Register it in your module’s index.module.js:
    hooks: {
      "launcher:view": [
        { trigger: "::view-id", component: "./views/View.svelte", meta: { title, icon, size } },
      ],
    }
    
  3. Wire a trigger from any element: data-launcher-view="::view-id", or call launcherView("::view-id") directly.