Skip to content

Choosing a plugin format

EmDash plugins use one of two formats: sandboxed or native. Choose the format before writing the plugin because the authoring shape, installation path, and trust boundary differ.

Choose a sandboxed plugin unless the plugin needs a native-only integration. Sandboxed plugins can be published to the registry and installed from the admin UI. A native plugin is an npm package that a site operator installs in the project and adds to astro.config.mjs before redeploying.

Sandboxed Native
Authoring shape emdash-plugin.jsonc + src/plugin.ts definePlugin() descriptor
Install method One-click from the admin registry npm install + edit astro.config
Runs in An isolated runtime provided by a sandbox runner Same process as your Astro site
Capability-gated ctx APIs Enforced by the sandbox bridge Gated by PluginContext, but not a security boundary
Resource limits Runner limits for CPU, subrequests, and wall time; platform memory ceiling No per-plugin limits
Network access ctx.http, restricted to declared access ctx.http follows declarations; native code can also call fetch()
Direct fetch() / process.env Blocked by the runner Possible (plugin code shares the runtime)
Distribution Signed release in the plugin registry npm package
Admin UI Block Kit (JSON-described) routes React components, or Block Kit
Settings UI Block Kit page + ctx.settings admin.settingsSchema (auto-form) or Block Kit
Portable Text rendering components Not available componentsEntry provides Astro components
Page metadata contributions page:metadata hook — meta/property tags, allowlisted <link> rels, JSON-LD page:metadata hook (same surface)
Page fragment injection Not available — meta/JSON-LD only via page:metadata page:fragments hook — inline scripts, external scripts, raw HTML
Constructor options None — read settings from KV at runtime options on the descriptor

Native plugins have a different installation and trust model:

  • Project-level installation. Every site has to install your npm package, edit astro.config.mjs, and redeploy.
  • No isolation. A bug in your plugin can crash the host process or burn its CPU budget. An unhandled rejection in a hook can take the surrounding request down with it.
  • Trust burden on the user. Native plugins have the same access as the host site. Capability declarations alone cannot show everything their code can do.

If your plugin can do its job in the sandbox, it should.

Choose native for features that need build-time integration with the host site:

  1. Custom React admin pages or widgets. Sandboxed plugins describe their admin UI with Block Kit — a JSON schema that the admin renders on the plugin’s behalf. If you need full React (custom hooks, third-party components, complex state), you need native.

  2. Custom Portable Text block types. Their editing configuration and Astro rendering components are loaded from the installed npm package. Only native plugins can provide that build-time surface.

  3. Injecting raw HTML, scripts, or stylesheets into public pages. The page:fragments hook ships first-party code to visitors’ browsers — outside any sandbox boundary. It’s restricted to native plugins. Sandboxed plugins can still contribute to public pages through the page:metadata hook, which covers a lot of real use cases:

    • meta tags (name + content) — SEO descriptions, robots directives, Twitter cards
    • property tags — OpenGraph and other property-based meta
    • link tags with a security-locked rel allowlist (canonical, alternate, author, license, nlweb, site.standard.document) — stylesheet, prefetch, and similar resource-loading rels are deliberately not allowed
    • JSON-LD graphs

    If your “page injection” need is structured data or SEO metadata, stay sandboxed and use page:metadata. If you actually need to ship JavaScript or HTML into the visitor’s browser, that’s the case for going native.

If none of these features apply, use the sandboxed format.

The sandbox itself is pluggable. EmDash exposes a sandboxRunner config option and the runner decides how plugin code is isolated — there’s nothing Cloudflare-specific in the plugin format itself.

Two runners ship with EmDash: sandbox() from @emdash-cms/cloudflare, which runs each plugin as a Dynamic Worker through Cloudflare’s Worker Loader, and @emdash-cms/sandbox-workerd/sandbox, which runs plugins in a workerd child process on Node.js. Plugin Sandbox covers the setup of each runner, the resource limits it enforces, and the differences between the two.

If no runner is configured, plugins listed under sandboxed: [] are not loaded. If the configured runner is unavailable on the current platform, they are not loaded either, and EmDash logs a warning at startup.

If you want a sandboxed plugin to run on a platform without a sandbox runner, move it from sandboxed: [] into the plugins: [] array — it’ll execute in-process. Capability declarations are still honoured (the same PluginContext factory gates ctx.content, ctx.http, and friends), but there is no isolation boundary, no resource limits, and a buggy or malicious plugin can call fetch() directly, read environment variables, or block the event loop. Without a sandbox runner active, treat every plugin as a native plugin for trust purposes.