A native plugin is an npm package that EmDash imports into the same process as the Astro site. This tutorial creates a plugin that logs content saves, installs it in a site, and registers it in astro.config.mjs.
Use the native format when the plugin needs an in-process feature such as React admin components, Astro rendering components, or trusted page fragments. If hooks, routes, storage, and Block Kit cover the feature, start with a sandboxed plugin. Choosing a plugin format compares the formats.
Prerequisites
Start with an EmDash site that uses pnpm and can run its development server.
The commands call the site directory my-emdash-site and create plugin-activity beside it. Replace my-emdash-site with your site’s directory name.
Create and register the package
-
Create a native package directory next to the site.
mkdir -p ../plugin-activity/src cd ../plugin-activity -
Add the package metadata and build scripts.
{ "name": "@example/plugin-activity", "version": "0.1.0", "type": "module", "main": "./dist/index.mjs", "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist"], "scripts": { "build": "tsdown src/index.ts --format esm --dts --clean", "dev": "tsdown src/index.ts --format esm --dts --watch", "typecheck": "tsc --noEmit" }, "peerDependencies": { "emdash": "*" }, "devDependencies": { "emdash": "*", "tsdown": "^0.20.0", "typescript": "^5.9.0" } }The npm package name is
@example/plugin-activity. The runtime code below uses the unscoped plugin IDplugin-activityso it fits in plugin API route URLs. -
Add the TypeScript configuration.
{ "compilerOptions": { "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "declaration": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } -
Install the package dependencies.
pnpm install -
Create
src/index.tswith a content-save hook.import { definePlugin } from "emdash"; import type { PluginDescriptor } from "emdash"; export interface ActivityPluginOptions { logUpdates?: boolean; } export function activityPlugin( options: ActivityPluginOptions = {}, ): PluginDescriptor<ActivityPluginOptions> { return { id: "plugin-activity", version: "0.1.0", format: "native", entrypoint: "@example/plugin-activity", options, }; } export function createPlugin(options: ActivityPluginOptions = {}) { return definePlugin({ id: "plugin-activity", version: "0.1.0", capabilities: ["content:read"], hooks: { "content:afterSave": async (event, ctx) => { if (!event.isNew && options.logUpdates === false) return; ctx.log.info("Content saved", { collection: event.collection, contentId: event.content.id, isNew: event.isNew, }); }, }, }); } export default createPlugin;content:afterSaverequires thecontent:readcapability. EmDash skips the hook when that capability is missing. -
Build the package.
pnpm build -
Install the local package in the site.
cd ../my-emdash-site pnpm add ../plugin-activity -
Register the descriptor factory in the EmDash integration.
import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { activityPlugin } from "@example/plugin-activity"; export default defineConfig({ integrations: [ emdash({ plugins: [activityPlugin({ logUpdates: true })], }), ], });Native descriptors belong in
plugins, notsandboxed. EmDash rejects a native descriptor in thesandboxedarray. -
Start the site and save an entry in the admin panel.
pnpm devThe server log includes
Content savedwith the collection, content ID, and whether the entry was created.
Descriptor and runtime boundary
The package export has two jobs. EmDash uses each at a different stage:
- The descriptor factory,
activityPlugin(), runs while Astro evaluates its configuration. It returns serializable build-time metadata:id,version,format,entrypoint, andoptions. React and Astro entrypoints also belong on this descriptor. - The named
createPlugin()export runs when EmDash initializes. EmDash imports it fromentrypoint, passes the serializedoptions, and expects a resolved plugin fromdefinePlugin().
The named createPlugin export is required. A default export may be useful to package consumers, but EmDash’s native loader imports createPlugin by name.
Keep id and version identical in the descriptor and definePlugin(). Use an unscoped, kebab-case plugin ID such as plugin-activity; keep the npm scope in the package name and entrypoint. This keeps the ID usable as the single plugin segment in API route URLs.
Plugin identity and versioning lists the accepted ID and version forms.
Runtime behavior belongs in definePlugin():
capabilitiesandallowedHostsstoragehooksandroutesadminsettings, page, widget, and Portable Text declarations
The descriptor carries the static entries that Astro must import or expose at build time. The focused guides show which admin fields need matching descriptor and runtime declarations.
Native route handlers
Native route handlers receive one RouteContext. It combines validated input and request data with the regular PluginContext:
routes: {
status: {
permission: "plugins:read",
handler: async (ctx) => ({
pluginId: ctx.plugin.id,
callerId: ctx.user?.id ?? null,
}),
},
},
The equivalent sandboxed handler receives (routeCtx, ctx) as two arguments. Authentication, permissions, input schemas, and route URLs otherwise follow the shared API routes contract.
Wrap a native route in definePluginRoute() when it declares request.body; the helper infers
ctx.input from the body mode. A native route with response: "raw" returns pluginResponse().
Import both helpers from emdash. The shared API route guide lists the body modes, limits, response
policy, and compatibility defaults.
Read secrets and bindings
Hook and route handlers in a native plugin receive the plugin context, not the Astro context, so Astro.locals is not available in them. Read secrets and platform bindings from the runtime environment.
Read deployment secrets from process.env. On Node.js, the hosting platform’s secret manager supplies them. On Cloudflare Workers, secrets set with wrangler secret put reach process.env through the nodejs_compat flag with a compatibility date of 2025-04-01 or later, as described in Worker secrets.
For a credential that a site administrator enters, declare a secret field in the generated settings form instead. EmDash encrypts the value with EMDASH_ENCRYPTION_KEY before storing it, so set that key before an administrator saves the field. The plugin reads the value with ctx.settings.get().
On Cloudflare Workers, bindings such as a queue, an R2 bucket, or send_email are objects on env from cloudflare:workers. Astro loads astro.config.mjs in Node.js, and that file imports the plugin package for the descriptor factory. Node.js cannot load cloudflare:workers, so import it inside the handler rather than at the top of the module. The following hook sends each saved entry’s ID to a queue bound as ACTIVITY_QUEUE:
hooks: {
"content:afterSave": async (event) => {
const { env } = await import("cloudflare:workers");
await env.ACTIVITY_QUEUE.send({ contentId: event.content.id });
},
},
The cloudflare:workers module resolves only in the Workers runtime, so a plugin that imports it works only on sites that use the Cloudflare adapter.
To type-check the import, add @cloudflare/workers-types to the package’s devDependencies and to compilerOptions.types in tsconfig.json. Then declare each binding the plugin reads on the Cloudflare.Env interface. The following declaration types the queue from the previous example:
declare namespace Cloudflare {
interface Env {
ACTIVITY_QUEUE: Queue;
}
}
Add another surface
- React admin pages and widgets covers settings, custom pages, dashboard widgets, field widgets, editor panels, and list columns.
- Portable Text rendering components registers Astro components for plugin blocks.
- Page fragments adds trusted scripts or HTML to public pages.
- Distributing native plugins packages the build and source entrypoints for npm.