Plugin Development

This guide covers creating custom plugins that extend Stately with new capabilities. Plugins are vertical - they span both backend (Rust) and frontend (TypeScript) to provide coordinated functionality.

Plugin Architecture

A Stately plugin consists of:

my-plugin/
├── crates/
│   └── my-plugin/             # Rust crate
│       ├── Cargo.toml
│       ├── src/
│       │   ├── lib.rs
│       │   ├── router.rs
│       │   ├── handlers.rs
│       │   └── ...
│       └── bin/
│           └── generate-openapi.rs

└── packages/
    └── my-plugin-ui/          # TypeScript package
        ├── package.json
        ├── src/
        │   ├── index.ts
        │   ├── plugin.ts
        │   ├── schema.ts
        │   └── ...
        └── openapi.json       # Generated from Rust

Development Guide

This page provides a complete guide to plugin development. The following topics are covered below:

Backend Topics

  • Creating the Rust crate
  • Axum router integration
  • FromRef pattern for state extraction
  • OpenAPI generation for type codegen

Frontend Topics

  • Creating the TypeScript package
  • Schema plugin for type system extensions
  • UI plugin for component registration
  • Component registry for field components

Quick Start

1. Backend Crate

// src/lib.rs
pub mod router;
pub mod handlers;
pub mod state;

pub use router::router;
pub use state::MyPluginState;
// src/router.rs
use axum::{Router, routing::get};
use axum::extract::FromRef;

pub fn router<S>(state: S) -> Router<S>
where
    S: Clone + Send + Sync + 'static,
    MyPluginState: FromRef<S>,
{
    Router::new()
        .route("/my-endpoint", get(handlers::my_handler))
        .with_state(state)
}
// src/state.rs
#[derive(Clone)]
pub struct MyPluginState {
    pub config: MyConfig,
}

2. Generate OpenAPI

// bin/generate-openapi.rs
use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(paths(handlers::my_handler), components(schemas(MyResponse)))]
struct OpenApiDoc;

fn main() {
    let output_dir = std::env::args().nth(1).unwrap_or_else(|| {
        eprintln!("Usage: my-plugin-openapi <output_dir>");
        std::process::exit(1);
    });

    match stately::codegen::generate_openapi::<OpenApiDoc;>(&output_dir) {
        Ok(path) => println!("OpenAPI spec written to {}", path.display()),
        Err(e) => {
            eprintln!("Failed to generate OpenAPI spec: {e}");
            std::process::exit(1);
        }
    }
}
cargo run --bin my-plugin-openapi > ../packages/my-plugin-ui/openapi.json

3. Frontend Package

// src/plugin.ts
import {
  createSchemaPlugin,
  type DefinePlugin,
} from '@statelyjs/stately/schema';
import {
  createUiPlugin,
  type DefineOptions,
  type DefineUiPlugin,
  type RouteOption,
  type UiNavigationOptions,
} from '@statelyjs/ui';
import { StarIcon } from 'lucide-react';

import { MY_OPERATIONS, type MyPluginPaths } from './api';
import type { MyPluginData, MyPluginNodeMap, MyPluginTypes } from './schema';
import { MyPluginNodeType } from './schema';
import { type MyPluginUiUtils, type MyPluginUtils, myPluginUiUtils } from './utils';

export const MY_PLUGIN_NAME = 'my-plugin' as const;


/**
 * Schema Plugin
 */

// Declare schema plugin definition
export type MyPlugin = DefinePlugin<
  typeof MY_PLUGIN_NAME,
  // Declare any new nodes this plugin's codegen will parse and generate
  MyPluginNodeMap,
  // Declare any schema types that should be accessible during plugin development
  MyPluginTypes,
  // Declare any derived schema structures
  MyPluginData,
  // Declare any plugin utilities
  MyPluginUtils,
>;

// Schema plugin - uses createSchemaPlugin for ergonomic API
export const myPlugin = createSchemaPlugin<MyPlugin>({
  name: MY_PLUGIN_NAME,
  // Optionally provide utilities (including validation hook)
  // utils: { ...myUtils, validate: myValidateHook },

  // Optionally provide a setup function for runtime data
  // setup: (ctx) => {
  //   const data = computeMyData(ctx.schema);
  //   return { data };
  // },
});

/**
 * UI Plugin
 */

// Declare the default route for any pages exported. User can override.
export const myPluginRoutes: RouteOption = { icon: StarIcon, label: 'My Plugin', to: '/my-plugin' };

// Define any options your ui plugin should accept
export type MyPluginUiOptions = DefineOptions<{
  /** API configuration for My Plugin endpoints */
  api?: { pathPrefix?: string };
  /** Navigation configuration for My Plugin routes */
  navigation?: { routes?: UiNavigationOptions['routes'] };
}>;

// Declare ui plugin definition
export type MyUiPlugin = DefineUiPlugin<
  typeof MY_PLUGIN_NAME,
  MyPluginPaths,
  typeof MY_OPERATIONS,
  MyPluginUiUtils,
  MyPluginUiOptions,
  typeof myPluginRoutes
>;

// UI plugin - uses createUiPlugin for ergonomic API
export const myUiPlugin = createUiPlugin<MyUiPlugin>({
  name: MY_PLUGIN_NAME,
  operations: MY_OPERATIONS,
  routes: myPluginRoutes,
  utils: myPluginUiUtils,

  setup: (ctx, options) => {
    // Register components for any node types this plugin introduces
    ctx.registerComponent(MyPluginNodeType.MyNewNodeType, 'edit', MyPluginNodeEdit);
    ctx.registerComponent(MyPluginNodeType.MyNewNodeType, 'view', MyPluginNodeView);

    // Extend extension points if needed
    // someExtension.extend(myExtension);

    // Return only what you're adding - no spreading required
    return {};
  },
});

4. Integration

Backend:

impl FromRef<AppState> for MyPluginState {
    fn from_ref(state: &AppState) -> Self {
        MyPluginState { config: state.my_config.clone() }
    }
}

let app = Router::new()
    .nest("/my-plugin", my_plugin::router(state.clone()));

Frontend:

// ...user imports openapi spec and parsed definitions...
import { type MyUiPlugin, myUiPlugin } from 'my-plugin';
import { type MyPlugin, myPlugin } from 'my-plugin/schema';

type AppSchemas = Schemas<
  DefineConfig<components, paths, operations, ParseSchema>,
  readonly [MyPlugin]
>;

const schema = stately<AppSchemas>(openApiSpec, PARSED_SCHEMAS)
  .withPlugin(myPlugin());

const runtime = statelyUi<AppSchemas, readonly [MyPlugin]>({ client, schema, core, options })
  .withPlugin(myUiPlugin({ api: { pathPrefix: '/my-plugin' } }));

Key Patterns

State Extraction

Use Axum's FromRef for flexible state composition:

pub struct MyPluginState { /* ... */ }

// Users implement this for their app state
impl FromRef<TheirAppState> for MyPluginState {
    fn from_ref(state: &TheirAppState) -> Self {
        // Extract plugin state from app state
    }
}

Component Registration

Register components for custom node types inside your plugin's setup function:

setup: (ctx, options) => {
  // Edit component for your node type
  ctx.registerComponent('MyNodeType', 'edit', MyNodeEdit);

  // View component
  ctx.registerComponent('MyNodeType', 'view', MyNodeView);

  return {};
},

The ctx.registerComponent() helper handles key generation automatically. Components are looked up by the form system when rendering fields of matching node types.

Codegen Plugin

Transform schemas during code generation:

export const myCodegenPlugin: CodegenPlugin = {
  name: 'my-plugin',
  description: 'Detects nodes relevant to my plugin\'s api and parses them',
  transform(schema, context) {
    if (matchesMyPattern(schema, context)) {
      return { description: schema?.description, nodeType: 'myCustomNodeType' };
    }
    return null;
  },
};

Styling

For Plugin Authors

Plugins should not ship pre-built Tailwind utility CSS. Instead:

  1. Export theme/token CSS only - If your plugin needs custom CSS variables or theme extensions, export them in a styles.css that imports only theme tokens (no utilities)
  2. Let consumers scan your dist - Consumers add @source "./node_modules/your-plugin" to their CSS, and Tailwind generates utilities from your compiled components

Example plugin styles.css:

/* Plugin theme extensions only - no @import "tailwindcss" */
@import "@statelyjs/ui/theme.css";

/* Any plugin-specific CSS variables */
:root {
  --my-plugin-accent: var(--stately-primary);
}

For Plugin Consumers

When using a plugin, ensure your @source directive covers it:

@import "tailwindcss";
@import "@statelyjs/stately/styles.css";
@import "your-plugin/styles.css";  /* If plugin exports theme CSS */

@source "./node_modules/@statelyjs";     /* Covers all @statelyjs packages */
@source "./node_modules/your-plugin";    /* Third-party plugins need their own @source */

This architecture prevents CSS conflicts between multiple plugins and the consuming app, since there's only one Tailwind build that generates all utilities.

Best Practices

  1. Keep backend and frontend in sync: Generate TypeScript types from Rust OpenAPI
  2. Use FromRef for state: Don't require specific app state types
  3. Provide sensible defaults: Make configuration optional where possible
  4. Document integration: Show users how to wire up both sides
  5. Export types: Let users access your types for their own extensions
  6. No pre-built utilities: Export only theme CSS, let consumers build utilities via @source

Examples

Study the built-in plugins for patterns:

Files:

Arrow:

Todo

[ ] Clarify how entrypoints => top-level schemas work.

Entrypoints provide a way to reduce size of parsed output, which could get quite large. When generating for a plugin, omitting entrypoints translates to "parse and store everything". This makes sense for plugin developers since visibility into all parsed nodes is required. But for users, the "entrypoints", and thus the "top level build-time parsed schemas", represent page entrypoints. Currently only stately's core provides those, in the form of entities. But if a plugin wishes to create a page and that page receives data via an API call, and the returned type is dynamic, but has a pre-defined shape, then an entrypoint should be introduced. The remaining schemas can be accessed via 'runtime schema', and accessed with 'loadRuntimeSchemas' on the "runtime.schema" object.

[ ] Clarify how "runtime schemas" works, how to access it, and how to introduce nodes into it.

[ ] Introduce a 'cookie cutter' like template