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
| Sandboxed | Native | |
|---|---|---|
| Authoring shape | emdash-plugin.jsonc + src/plugin.ts | definePlugin() descriptor |
| Install method | One-click from the admin marketplace | 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 | .tar.gz bundle on the marketplace | npm package |
| Admin UI | Block Kit (JSON-described) routes | React components, or Block Kit |
| Settings UI | Block Kit page + KV reads | 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 |
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:
-
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.
-
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.
-
Injecting raw HTML, scripts, or stylesheets into public pages. The
page:fragmentshook 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 thepage:metadatahook, which covers a lot of real use cases:metatags (name+content) — SEO descriptions, robots directives, Twitter cardspropertytags — OpenGraph and other property-based metalinktags 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.