Skip to content

Choosing a plugin format

EmDash plugins ship in one of two formats: sandboxed or native. The choice affects how the plugin is installed, what enforcement it gets at runtime, and which features are available.

Default to sandboxed. Sandboxed plugins can be published to the marketplace and installed from the admin UI with one click. Native plugins require a code change, an npm install, and a redeploy on every site that wants them. Sandboxed is what end users want.

Choose native only when you need a feature the sandbox can’t provide.

SandboxedNative
format field on descriptor"standard""native"
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
CapabilitiesEnforced by the sandbox bridgeEnforced in-process by the same bridge
Resource limitsEnforced by the runner — typically CPU, subrequests, wall-time, memoryNone
Network accessctx.http only, restricted to allowedHostsctx.http only, restricted to allowedHosts
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

Native plugins look like a more powerful version of the same thing, and they are — but the cost is steep:

  • No marketplace. 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. End users can’t audit them through capability declarations alone.

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

There are three reasons to choose native, and they’re all about 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. Astro components for rendering Portable Text blocks on the public site. A plugin can declare a custom block type with format: "standard", but the Astro components that render it on the public site have to be loaded at build time from npm. Only native plugins can provide a componentsEntry.

  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 you’re not sure, go sandboxed. You can always migrate to native later — but the reverse is harder, because native-only features have no sandbox equivalent.

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.

The runner most sites use today is sandbox() from @emdash-cms/cloudflare, which uses Cloudflare Workers’ Dynamic Worker Loader. Worker Loader caches the V8 isolate per plugin id so the isolate cold-start cost is only paid once; the runner constructs a fresh worker stub and bridge bindings on each invocation, since stubs and bindings are tied to the calling request’s I/O context. Runners for other platforms (Node.js via workerd, and potentially Deno) are in development.

If no runner is configured, or if the configured runner reports as unavailable on the current platform, plugins listed under sandboxed: [] are skipped at startup with a debug-level log.

If you want a standard-format 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.