Distributing native plugins
Native plugins are npm packages installed in the host project and registered in astro.config.mjs. The package needs a built server entry for its descriptor and createPlugin(). If it also ships React or Astro components, export those as separate source entrypoints so the host can compile them for the correct environment.
Package layout
Section titled “Package layout”The following layout separates the server runtime from browser and Astro source:
plugin-activity/├── src/│ ├── index.ts│ ├── admin/│ │ ├── index.tsx│ │ └── ActivityPage.tsx│ └── astro/│ ├── index.ts│ └── ActivityBlock.astro├── dist/│ ├── index.mjs│ └── index.d.mts├── package.json├── tsconfig.json└── README.mddist/ is generated. Keep src/admin/ and src/astro/ in the published tarball because the host’s Vite and Astro build must process those entrypoints.
Package exports
Section titled “Package exports”The following package.json builds the server entry and publishes all three entrypoints:
{ "name": "@example/plugin-activity", "version": "0.1.0", "type": "module", "main": "./dist/index.mjs", "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, "./admin": "./src/admin/index.tsx", "./astro": "./src/astro/index.ts" }, "files": ["dist", "src/admin", "src/astro"], "scripts": { "build": "tsdown src/index.ts --format esm --dts --clean", "dev": "tsdown src/index.ts --format esm --dts --watch", "typecheck": "tsc --noEmit", "prepublishOnly": "pnpm typecheck && pnpm build" }, "peerDependencies": { "@cloudflare/kumo": "*", "@emdash-cms/admin": "*", "@lingui/core": "*", "@lingui/react": "*", "@tanstack/react-query": "*", "astro": ">=6.0.0-beta.0", "emdash": "*", "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@types/react": "^19.0.0", "tsdown": "^0.20.0", "typescript": "^5.9.0" }, "keywords": ["emdash", "emdash-plugin"], "license": "MIT"}Remove ./admin, src/admin, and the admin-only peer dependencies when the plugin has no trusted React UI. Remove ./astro, src/astro, and the astro peer when it has no Portable Text renderer. Add a peer dependency for every host-owned library imported by an exported source entrypoint; this prevents a second React, Kumo, Lingui, or React Query instance from entering the admin bundle.
The entrypoints have different consumers:
| Export | Required when | Consumer |
|---|---|---|
. |
Always | Astro configuration imports the descriptor factory; EmDash imports the named createPlugin() at runtime. |
./admin |
adminEntry is set |
The host’s browser build imports the React component maps. |
./astro |
componentsEntry is set |
The host’s Astro build imports blockComponents. |
The module specifiers in the descriptor and runtime must match these exports:
export function activityPlugin(): PluginDescriptor { return { id: "plugin-activity", version: "0.1.0", format: "native", entrypoint: "@example/plugin-activity", adminEntry: "@example/plugin-activity/admin", componentsEntry: "@example/plugin-activity/astro", };}
export function createPlugin() { return definePlugin({ id: "plugin-activity", version: "0.1.0", admin: { entry: "@example/plugin-activity/admin", }, });}Keep the npm package version, descriptor version, and definePlugin() version synchronized. The version shown to a site administrator comes from the plugin definition, not automatically from package.json.
Plugin identity and version
Section titled “Plugin identity and version”definePlugin() accepts either an unscoped ID containing lowercase letters, digits, and hyphens, or a scoped ID in the form @scope/name. Use an unscoped, kebab-case ID for a site plugin because the ID also occupies one path segment in /_emdash/api/plugins/<plugin-id>/<route>.
The following values show the accepted forms and the recommended separation between plugin ID and npm package name:
id: "plugin-activity"; // Recommended: valid in plugin route URLsid: "@example/plugin-activity"; // Accepted by definePlugin(), but not one URL segment
entrypoint: "@example/plugin-activity"; // The npm package may stay scopedVersions must begin with a semantic major.minor.patch sequence. Use a complete semantic version for both the descriptor and runtime:
version: "1.0.0"; // Validversion: "1.2.3-beta.1"; // Valid prereleaseversion: "1.0"; // Invalid: missing patch versionTypeScript configuration
Section titled “TypeScript configuration”The native scaffold creates a suitable tsconfig.json. If the plugin adds React and Astro source after scaffolding, include both JSX environments:
{ "compilerOptions": { "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", "strict": true, "declaration": true, "outDir": "./dist", "rootDir": "./src", "jsx": "react-jsx", "types": ["astro/client"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}Run pnpm typecheck against the source entrypoints before packaging. The build script compiles only src/index.ts; the host compiles the exported admin and Astro source when it consumes the package.
Inspect the package
Section titled “Inspect the package”Test the exact tarball contents before publishing. The commands below assume a disposable site named my-emdash-site sits beside the plugin directory.
-
Build and type-check the package.
Terminal window pnpm typecheckpnpm build -
Create the npm tarball and review the file list printed by npm.
Terminal window npm packFor the example package, npm creates
example-plugin-activity-0.1.0.tgz. -
Confirm that the output contains
dist/index.mjs,dist/index.d.mts, and every source file reachable from the exported./adminand./astromodules. -
Install the produced tarball in the disposable EmDash site.
Terminal window cd ../my-emdash-sitepnpm add ../plugin-activity/example-plugin-activity-0.1.0.tgz -
Import and register the descriptor factory in the disposable site’s
astro.config.mjs, following Create and register the package. Then build the host site.Terminal window pnpm buildOpen every plugin admin surface and render every contributed Portable Text block. Registering before the build makes Astro resolve the tarball’s
./adminand./astroexports; a server-only package test cannot catch a missing browser or.astrosource file.
README contents
Section titled “README contents”Give an operator enough information to install and evaluate the package without reading its source. Include:
- a one-sentence description and the supported EmDash version
- the install command and complete
astro.config.mjsregistration - the native trust boundary and why the plugin needs native execution
- every declared capability and allowed host, with the feature that uses it
- settings and their defaults
- required layout components, such as
EmDashBodyEndfor a body-end fragment - upgrade steps for changes that require operator action
Do not describe capability declarations as an isolation boundary. They gate ctx APIs, but native code can still use imports, environment variables, and direct network calls available to the host process.
Publish to npm
Section titled “Publish to npm”Publish after the tarball test passes:
npm publish --access publicThe first public release of a scoped package needs --access public. Use semantic versioning for later releases. Treat changes to constructor options, stored data, required host changes, package exports, or the plugin’s trust requirements as compatibility decisions. If an upgrade requires a new capability or allowed host, call it out in the release notes even though native installs do not have a capability-consent prompt.
Install from npm
Section titled “Install from npm”An operator installs the published package in the EmDash site:
pnpm add @example/plugin-activityThen import and register its descriptor factory in astro.config.mjs as shown in Create and register the package. Installing the dependency alone does not activate the plugin; changing the Astro configuration and deploying the site completes the installation.
Develop against a host site
Section titled “Develop against a host site”Build the plugin in watch mode:
pnpm devInstall the local directory from the host site:
pnpm add ../plugin-activityRegister the plugin’s descriptor factory in astro.config.mjs, then start the host development server. Restart the server after changing descriptor metadata or package exports. If a package-manager file dependency copies files instead of linking them in your setup, reinstall it after rebuilding; a workspace dependency or pnpm link keeps the local package connected during development.
Registry boundary
Section titled “Registry boundary”Native packages cannot be published to the EmDash registry. Registry plugins use the sandboxed package format, signed release workflow, and installation consent flow. If the plugin no longer needs React admin code, Astro renderers, trusted fragments, or another in-process dependency, convert it to the sandboxed format before publishing through the registry.