Your first native plugin

On this page

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

  1. Create a native package directory next to the site.

    mkdir -p ../plugin-activity/src
    cd ../plugin-activity
  2. 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 ID plugin-activity so it fits in plugin API route URLs.

  3. 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"]
    }
  4. Install the package dependencies.

    pnpm install
  5. Create src/index.ts with 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:afterSave requires the content:read capability. EmDash skips the hook when that capability is missing.

  6. Build the package.

    pnpm build
  7. Install the local package in the site.

    cd ../my-emdash-site
    pnpm add ../plugin-activity
  8. 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, not sandboxed. EmDash rejects a native descriptor in the sandboxed array.

  9. Start the site and save an entry in the admin panel.

    pnpm dev

    The server log includes Content saved with 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, and options. React and Astro entrypoints also belong on this descriptor.
  • The named createPlugin() export runs when EmDash initializes. EmDash imports it from entrypoint, passes the serialized options, and expects a resolved plugin from definePlugin().

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():

  • capabilities and allowedHosts
  • storage
  • hooks and routes
  • admin settings, 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