Choosing a plugin format

On this page

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.

At a glance

SandboxedNative
Authoring shapeemdash-plugin.jsonc + src/plugin.tsdefinePlugin() descriptor
Install methodOne-click from the admin marketplacenpm install + edit astro.config
Runs inAn isolated runtime provided by a sandbox runnerSame process as your Astro site
Capability-gated ctx APIsEnforced by the sandbox bridgeGated by PluginContext, but not a security boundary
Resource limitsRunner limits for CPU, subrequests, and wall time; platform memory ceilingNo per-plugin limits
Network accessctx.http, restricted to declared accessctx.http follows declarations; native code can also call fetch()
Direct fetch() / process.envBlocked by the runnerPossible (plugin code shares the runtime)
Distribution.tar.gz bundle on the marketplacenpm package
Admin UIBlock Kit (JSON-described) routesReact components, or Block Kit
Settings UIBlock Kit page + KV readsadmin.settingsSchema (auto-form) or Block Kit
Portable Text rendering componentsNot availablecomponentsEntry provides Astro components
Page metadata contributionspage:metadata hook — meta/property tags, allowlisted <link> rels, JSON-LDpage:metadata hook (same surface)
Page fragment injectionNot available — meta/JSON-LD only via page:metadatapage:fragments hook — inline scripts, external scripts, raw HTML
Constructor optionsNone — read settings from KV at runtimeoptions on the descriptor

Costs of a native plugin

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.

When to go native

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.

Sandbox runners and platform support

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.

Next