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
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.md
dist/ 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
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
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 URLs
id: "@example/plugin-activity"; // Accepted by definePlugin(), but not one URL segment
entrypoint: "@example/plugin-activity"; // The npm package may stay scoped
Versions must begin with a semantic major.minor.patch sequence. Use a complete semantic version for both the descriptor and runtime:
version: "1.0.0"; // Valid
version: "1.2.3-beta.1"; // Valid prerelease
version: "1.0"; // Invalid: missing patch version
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
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.
pnpm typecheck pnpm build -
Create the npm tarball and review the file list printed by npm.
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.
cd ../my-emdash-site pnpm 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.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
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
Publish after the tarball test passes:
npm publish --access public
The 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
An operator installs the published package in the EmDash site:
pnpm add @example/plugin-activity
Then 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
Build the plugin in watch mode:
pnpm dev
Install the local directory from the host site:
pnpm add ../plugin-activity
Register 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.
Marketplace boundary
Native packages cannot be published to the EmDash marketplace. Marketplace plugins use the sandboxed package format, bundle 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 marketplace.