Skip to content
Documentation

Setting Up Your Development Environment

This guide covers how to set up your development environment for plugin development.

A plugin in Neleto is just a normal webserver. This means you can write plugins in any programming language.

The only 2 requirements for your webserver to work as a plugin are:

  1. the plugin.json manifest file. This file defines the name, version, start-command (bin), and metadata of your plugin. Here is a minimal example:
{
    "name": "example-plugin",
    "version": "0.1.0",
    "bin": "bun run main.js"
}
  1. the packaged plugin needs to include everything needed to run it under an x86 ubuntu 24.04 docker container. This includes runtimes for programming languages like javascript, meaning you must bundle them with your plugin. In the case of Bun you can use the --compile when building your plugin to include the bun runtime.

Runtime contract

When Neleto starts your plugin it sets a few environment variables. Your server must read them rather than hard-coding values:

VariableAlways setWhat it is
PORTyesThe port your webserver must listen on. Bind 0.0.0.0:$PORT.
DATABASE_URLyesA PostgreSQL connection string for your plugin's own database (see below).

Listen on PORT, binding all interfaces:

const port = Number(process.env.PORT) || 3000;
Bun.serve({ port, fetch: handler });
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;

Your plugin's database

Every plugin gets its own PostgreSQL database, separate from the CMS and from other plugins. The connection string is injected as DATABASE_URL — you don't provision anything. Create your tables by running your migrations on startup:

let db = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
sqlx::migrate!("./migrations").run(&db).await?; // ship migrations in the package

Include your migrations directory in include_files so it ships in the package.

Reading CMS content

To read or write the site's pages, posts, files and so on, call the CMS from your plugin over its REST API (or let an agent use the MCP tools). That is the supported, stable interface for content — don't reach into the CMS's own database.

Logging

Write to stdout / stderr. Neleto captures both and shows them in the plugin's Logs tab in the admin (see Managing Plugins), which is the first place to look when a plugin misbehaves.

The manifest file plugin.json

To integrate your plugin more with Neleto, you can supply more values in the plugin.json. The plugin.json Manifest file needs to conform to this typescript type:

export type ApiEndpoint = {
    /**
     * If omitted, all logged-in users can access this route. Each entry is a
     * permission id like `"page.update"`; the user needs ANY of them (OR semantics).
     */
    allowed_permissions?: Array<string>;
    /**
     * This is the route, in a format like this `/foo/:bar/:somepath*`
     */
    route: string;
};

export type PluginHelper = {
    /**
     * The name of the helper
     */
    name: string;
    /**
     * The route, where the helper is located, this route will get the arguments as input and should return the value
     */
    route: string;
} & (
    | {
          type: "inline";
          /**
           * The argument types this helper expects (length = number of arguments).
           * Accepts either `{ name, type }` objects or a bare list of types,
           * e.g. `["String", "Int"]`.
           */
          arguments: Array<FunctionArgument | VariableType>;
          /**
           * The type of the return value of this helper
           */
          return_value: VariableType;
      }
    | {
          /**
           * A block helper, like the `if` helper: `{{#if <arguments>}}<body>{{/if}}`
           */
          type: "block";
          arguments: Array<VariableType>;
      }
);

export type FunctionArgument = { name: string; type: VariableType };

export type PluginManifest = {
    /**
     * The name of the plugin
     */
    name: PluginName;
    /**
     * The version of the plugin (semver)
     */
    version: string;
    /**
     * Everything needed to run your plugin — executables, dynamically loaded
     * libraries, assets, bundled runtimes — that must be included in the built
     * package. Glob patterns are allowed. Required.
     */
    include_files: Array<string>;
    /**
     * The command to start the plugin
     */
    bin?: string | null;
    /**
     * Rewriter is a route your plugin handles. this route will be called with POST whenever a
     * page is rendered, with the rendered html in the request body, and the response of this
     * route will be forwarded to either the next plugin, or the render output
     */
    rewriter?: string;
    /**
     * Here you can define routes which function as handlebars helpers when rendering
     */
    helpers?: Array<PluginHelper>;
    /**
     * Here you can define routes which are used as pages when a user visits the website. These
     * routes do not have the prefix in front of their path when called.
     */
    pages?: Array<PluginPage>;
    /**
     * Here you can define routes which are used as authenticated api endpoints. The key is the
     * route, in a format like this `/foo/:bar/:somepath*`. These routes have the prefix
     * `/api/rest/plugins/<your-plugin-name>/api`, when called from the client, as they get
     * proxied through the cms backend.
     */
    api?: Array<ApiEndpoint>;
    /**
     * This is the path to where your plugin defines presets for components, this path needs to be
     * included in your built plugin package Components need to be provided in folders with these
     * files inside: `template.hbs`, `script.js`, `style.css`, `.meta.json` (required),
     * `form.json` (required)
     */
    components?: string | null;
    /**
     * This is the path to where your plugin defines presets for page-layouts, this path needs to
     * be included in your built plugin package Layouts need to be provided in folders with these
     * files inside: `template.hbs`, `script.js`, `style.css`, `.meta.json` (required)
     */
    layouts?: string | null;
    /**
     * Routes used to render dashboard cards shown in the cms dashboard.
     */
    dashboard_cards?: Array<PluginDashboardCard>;
    /**
     * This route will be used as the entrypoint to the ui of your plugin. These routes have the
     * prefix `/api/rest/plugins/<your-plugin-name>/ui`. The access to these ui routes is only
     * restricted to users who can also manage plugins.
     */
    ui?: string | null;
    /**
     * Path (relative to the plugin root) to a hero image bundled in the package, shown as a wide
     * banner on the plugin's card and detail page in the plugin store. Recommended: a ~3:1
     * banner, e.g. 1600×540, PNG/JPG/WebP/SVG, ≤ 500 KB.
     */
    hero_image?: string | null;
};

export type PluginName = string;

export type PluginPage = {
    /**
     * The name of the page
     */
    name: string;
    /**
     * This is the route, in a format like this `/foo/:bar/:somepath*`
     */
    route: string;
    /**
     * Allow selecting a layout for this page in the cms. When a layout is selected the page is
     * rendered inside it (see First Steps -> Page-endpoint). Defaults to false.
     */
    allow_select_layout?: boolean;
    /**
     * Generate tailwind classes for the selected layout. Has no effect if allow_select_layout is
     * not enabled.
     */
    enable_tailwind?: boolean;
};

export type PluginDashboardCard = {
    /**
     * Unique (within this plugin) id for the card. Not shown to the user; used to persist which
     * cards a user chose to display, so it must never change.
     */
    name: string;
    /**
     * Label shown when the user selects which dashboard cards to display.
     */
    label: Translated;
    /**
     * Optional longer description shown to the user.
     */
    description?: Translated | null;
    /**
     * The route your plugin exposes to render the entire dashboard card.
     */
    route: string;
};

/** A translated string, keyed by language code (`en` and `de` required). */
export type Translated = { en: string; de: string } & Record<string, string>;

/** The type system used by helper arguments and return values. */
export type VariableType =
    | "String"
    | "Number"
    | "Int"
    | "Float"
    | "Boolean"
    | "Any"
    | "Never"
    | "Nullish"
    | { Object: Record<string, VariableType> }
    | { Array: VariableType }
    | { Literal: string }
    | { IntLiteral: number }
    | { BooleanLiteral: boolean }
    | { Option: VariableType }
    | { Union: Array<VariableType> }
    | { Record: { key: VariableType; values: VariableType } }
    | { Named: string }
    | { Function: { name: string; args: Array<FunctionArgument | VariableType>; return_type: VariableType } };

Schema & editor validation

The TypeScript type above is for reading. The authoritative, machine-readable schema is published as JSON Schema (Draft 2020-12) and is always generated from the backend, so it never drifts:

SchemaURL
Plugin manifest (plugin.json)https://neleto.io/schema/plugin/v1/manifest.json
Component form (form.json)https://neleto.io/schema/plugin/v1/form.json
Helper variable typeshttps://neleto.io/schema/plugin/v1/variable-type.json
Plugin store indexhttps://neleto.io/schema/plugin/v1/repository.json

Add a $schema key to your plugin.json to get autocompletion and inline validation in editors like VS Code:

{
    "$schema": "https://neleto.io/schema/plugin/v1/manifest.json",
    "name": "example-plugin",
    "version": "0.1.0",
    "include_files": ["main"]
}
name, version and include_files are required. include_files lists everything (executables, assets, runtimes) that must be bundled into the packaged plugin — a manifest without it will not validate.

Routes with this format /foo/:bar/:somepath* follow these rules:

PatternKindDescription
:nameNormalMatches a path piece, excludes /
:name?OptionalMatches an optional path piece, excludes /
/:name?/ /:name?OptionalSegmentMatches an optional path segment, excludes /, prefix or suffix should be /
+ :name+OneOrMoreMatches a path piece, includes /
* :name*ZeroOrMoreMatches an optional path piece, includes /
/*/ /* /:name*/ /:name*ZeroOrMoreSegmentMatches zero or more path segments, prefix or suffix should be /
CaseParameters
:a:ba b
:a:b?a b
:a-:b :a.:b :a~:ba b
:a_a-:b_ba_a b_b
:a\\: :a\\_a
:a\\::b :a\\_:ba b
:a*a
**1
*.**1 *2
:a+a
++1
+.++1 +2
/*/abc/+/def/g*1 +2