Adding a blog to most frontend projects touches five files in five different folders. A route here. A layout there. A nav entry in a config file. A stylesheet import in the global sheet. Maybe a store, maybe a type.

None of those five files knows about the other four. That’s the problem.

Removing the blog six months later means finding all five again — and you will miss one. It’ll be the nav entry, and it’ll sit there pointing at a 404 until someone reports it.

I built Standard because I got tired of that. In it, a feature is a folder. Adding the folder adds the feature. Deleting the folder removes it completely. The navigation updates itself, because nothing ever wrote the navigation down.


What a Module Actually Is

A module is a directory with a manifest at its root. Here’s the real one from the documentation site you might be reading this on:

export default {
  id: "manual",
  name: "Manual",
  description: "Renders markdown content using generated permalinks.",
  routes: [
    { path: "/manual/", entrypoint: "./index.astro" },
    { path: "/manual/[...slug]", entrypoint: "./[...slug].astro" },
  ],
};

Beside that file sit the things it declares: index.astro[...slug].astro, a layout/ folder, a content/ folder. Everything the feature needs, in one place, owned by one thing.

The full vocabulary is a little larger:

export default {
  id: "module-name",
  hooks: { /* logic and interface, see below */ },
  routes: [{ path: "/my-route", entrypoint: "./routes/index.astro" }],
  styles: ["./styles.scss"],
  scripts: ["./client-side.js"],
  middleware: "./middleware.js",
};

That’s the whole surface. A module declares what it contributes; it never reaches out to modify anything global.


Nothing Registers Anything

The part that matters isn’t the manifest — it’s that no central file lists the modules. They’re discovered.

There is no modules.ts with an array of imports. No registry to update. No place where a developer can forget to add the line, and no place where two people can create a merge conflict adding two lines at once.

This is the same instinct I use everywhere now, including in a house full of Zigbee switches: a mood named Evening lives in script.mood_evening, and the naming convention is the registry. Convention removes an entire category of bug — the category where two sources of truth quietly drift apart.

The cost is real and worth naming: discovery is magic, and magic is harder to debug than an import list. My mitigation is that misconfiguration fails at build time, loudly, rather than at runtime, quietly. More on that below.


Hooks: How Modules Talk Without Knowing Each Other

Routes are the easy part. The interesting problem is a module that needs to contribute into something it doesn’t own — a banner in the header, an item in a menu, a step during startup.

Standard has one mechanism for this, and it splits by file extension:

hooks: {
  "astro:config:setup": "./hooks/setup.js",      // .js  → logic
  "header:top": "./components/Banner.astro",     // .astro → interface
  "app:init": ["./init.ts", "./Widget.astro"],   // both is fine
}

.js or .ts handler is logic. A .astro.svelte or .md handler is interface. You don’t declare which — the extension decides, so the two can’t disagree.

On the consuming side, a layout just marks the spot:

<Hook id="header:top" />

The layout doesn’t know which modules will fill it. Possibly none. Possibly four. It doesn’t care, and that’s the entire point.


Two Ways to Call, and Why Both Exist

This is the design decision I’d defend hardest, because I got it wrong first.

runHook(name, ...args) fans a call out to every registered handler and collects the results into an array. Use it when handlers are independent and order doesn’t matter — build-time notifications, RSS registration, analytics.

runPipeline(name, value, ...args) threads a single value through every handler in sequence. Each one receives the current value and must return it, transformed or not.

The pipeline exists because of a case I couldn’t solve with fan-out. On my publishing platform, a note renders through a note:render pipeline. A password-gate module inspects every note and, when the note is locked, replaces the rendered output with a password form.

The route that renders notes has never heard of the password module. It doesn’t import it, doesn’t check a flag, doesn’t branch. It just threads the value through and uses whatever comes out the other end.

That’s what lets a feature be genuinely removable. If the route had an if (note.isLocked) in it, deleting the module would break the route.


Failures Are Isolated, Mistakes Are Not

Two rules keep the magic honest.

A throwing handler is logged and skipped. One broken module doesn’t take down the page. Every other handler still runs. In a system where features are meant to be added and removed casually, one bad addition must not be able to break everything else.

Misclassification is a fatal build error. If the same hook name is registered as logic in one module and as interface in another, the build stops. It doesn’t pick one and carry on. Since the classification is implicit — driven by file extension — the enforcement has to be explicit, or the implicitness becomes a trap.

There’s a third rule that only appears once you’re server-rendering. A handler marked server: true is excluded from the client bundle:

hooks: { "app:init": { entrypoint: "./db-thing.ts", server: true } }

Without it, a handler touching the database or a secret would be bundled to the browser, because client code imports from the same virtual module. And setting server: true on a hook that has no server/client split is also a fatal error rather than a silent no-op — a flag that does nothing is worse than a flag that doesn’t exist.


Turning a Feature Off

My documentation site has one line in its Astro config that I’m fond of:

standard({
  logLevel: "DEBUG",
  // A standards manual does not celebrate.
  moduleExclude: ["@stnd/modules/confetti"],
})

There is a confetti module. The manual doesn’t want it. One line, in the app that made the decision, rather than a flag threaded down through the framework.

That’s the shape of the whole system: decisions live where they’re made.


What It Costs

Auto-discovery is genuinely harder to trace than an import list. When something appears on a page and you don’t know why, you’re grepping for hook names instead of following imports. I’ve mitigated it with build-time errors and a debug log level, not solved it.

Hook names are strings, so they’re a namespace without a compiler. header:top is a contract that nothing enforces except convention and my memory of what I named things.

And the vertical slice makes cross-cutting changes harder, not easier. Restyling every module’s headings means touching every module. That’s the trade: cheap to add and remove one feature, expensive to change all of them at once. For a system where features come and go, it’s the right trade. For a system with one stable feature set, it would be the wrong one.


The Takeaway

Most frontend architecture problems are really problems of where a decision is written down. Five files in five folders means five places for the truth to live and one place for it to be forgotten.

A feature that owns its own routes, styles, scripts, hooks and content isn’t a clever abstraction. It’s just putting the decision next to the thing it decides.

Add a folder, everything works. Delete it, everything still works.

github.com/ZeFish/Standard — the framework, and the documentation site built with it.


Francis Fontaine is a developer and photographer based in Québec City. He builds modular systems for the web (stnd.build) and for the physical world, and believes the best technology is the kind that disappears into the background of daily life.

Are you absolutely sure?

This action cannot be undone.