Back to Article List

Build custom n8n nodes with the n8n-node CLI

Build custom n8n nodes with the n8n-node CLI

Two commands get you a working node project on n8n 2.x, and the second one starts an n8n instance with your node already loaded:

npm install --global @n8n/node-cli
n8n-node new

That's the whole toolchain now. The n8n-nodes-starter repository from older guides still exists and the docs still link it, but the @n8n/node-cli package is what n8n asks you to use, and for a verified community node it's mandatory: the development environment page says "When building verified community nodes, you must use the n8n-node tool to create and test your node." This guide builds a small declarative node for an internal API with it, loads the result into a Docker instance on a VPS through N8N_CUSTOM_EXTENSIONS, then publishes it to npm so it installs from Settings like any other community node.

Node.js version for the toolchain

The docs' setup page states "Minimum version Node 22.22.0" for node development. The published n8n 2.38 package itself declares Node 24 or newer, and n8n-node dev runs a local n8n, so 24 is the version to install with nvm and be done with it. Your production instance runs in Docker with its own bundled Node, so this only affects the machine you build on.

n8n-node new and the three templates

n8n-node new asks a few questions and writes the project; the n8n-node tool docs list every command and there aren't many. Answer them or pass the choices as flags: --template takes one of declarative/github-issues, declarative/custom or programmatic/example, and --skip-install leaves dependency installation to you. The scaffold without a global install is npm create @n8n/node@latest, with a bare -- before any flags.

The github-issues template is a complete, working declarative node against the GitHub API and the one I'd pick to learn from, because you can read a real credentials file, a real resource and operation list and real routing in ten minutes. declarative/custom is the empty version of the same. programmatic/example is where trigger nodes and anything with an execute() method start.

Declarative vs programmatic nodes

n8n's guidance on choosing a node building style is declarative for most nodes, and programmatic only when you need one of four things: a trigger node, a non-REST API such as GraphQL or anything with external dependencies, a node that transforms the incoming data or full versioning. A declarative node has no execute(). You describe the request in a routing key under each operation and n8n builds and sends it. A programmatic node has an execute() method that reads the input items and parameters, does whatever it wants and returns items.

I pick declarative every time the API is REST, even when I know I'll want a transform later, since the transform can be a Code node after it. The declarative file is shorter, its harder to introduce a bug in and the routing object survives n8n upgrades better than hand-written request code. Programmatic nodes I only write for triggers.

Node file anatomy: description, properties and NodeConnectionTypes

Every node is a TypeScript class implementing INodeType with a description object. Everything is imported from n8n-workflow; the old n8n-core import for IExecuteFunctions that older examples use is gone from the current base file reference. Inputs and outputs are written as [NodeConnectionTypes.Main], not the string 'main'. Here's the node this guide builds, reading open incidents from a made-up internal status API:

import {
  INodeType,
  INodeTypeDescription,
  NodeConnectionTypes,
} from 'n8n-workflow';

export class StatusPage implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Status Page',
    name: 'statusPage',
    icon: 'file:statuspage.svg',
    group: ['transform'],
    version: 1,
    description: 'Read incidents from the internal status API',
    defaults: {
      name: 'Status Page',
    },
    inputs: [NodeConnectionTypes.Main],
    outputs: [NodeConnectionTypes.Main],
    credentials: [
      {
        name: 'statusPageApi',
        required: true,
      },
    ],
    requestDefaults: {
      baseURL: 'https://status.internal.example',
      headers: {
        Accept: 'application/json',
      },
    },
    properties: [
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        options: [
          {
            name: 'Get Open Incidents',
            value: 'getOpen',
            action: 'Get open incidents',
            routing: {
              request: {
                method: 'GET',
                url: '/v1/incidents',
                qs: {
                  state: 'open',
                },
              },
            },
          },
        ],
        default: 'getOpen',
      },
    ],
  };
}

The fields in description and what each accepts are on the standard parameters reference. displayName is what users see, name is the internal id, group is one of trigger, schedule, input, output or transform, icon can be a single file or a light/dark pair, requestDefaults must carry baseURL and properties is the array of parameters the node shows in the editor.

The credentials file

Credentials are a second class in credentials/StatusPageApi.credentials.ts, implementing ICredentialType. It declares the fields the user fills in, an authenticate block of type generic that injects the value into headers or query string and a test request n8n runs when the user clicks the test button. The name here (statusPageApi) is what the node's credentials array references. Secrets never go into node properties; the credential is encrypted in n8n's database with the instance key and the node never sees the raw value unless it asks for it.

import {
  IAuthenticateGeneric,
  ICredentialTestRequest,
  ICredentialType,
  INodeProperties,
} from 'n8n-workflow';

export class StatusPageApi implements ICredentialType {
  name = 'statusPageApi';
  displayName = 'Status Page API';
  documentationUrl = 'https://status.internal.example/docs';
  properties: INodeProperties[] = [
    {
      displayName: 'API Token',
      name: 'token',
      type: 'string',
      typeOptions: { password: true },
      default: '',
    },
  ];
  authenticate: IAuthenticateGeneric = {
    type: 'generic',
    properties: {
      headers: {
        Authorization: '=Bearer {{$credentials.token}}',
      },
    },
  };
  test: ICredentialTestRequest = {
    request: {
      baseURL: 'https://status.internal.example',
      url: '/v1/me',
    },
  };
}

The codex file and the package.json n8n block

Next to StatusPage.node.ts sits StatusPage.node.json, the codex file. The docs are strict about the name: "The codex filename must match the node base filename." It holds node, nodeVersion, codexVersion (currently 1.0), categories for where the node appears in the panel and resources for documentation links. Then package.json needs the block that tells n8n which built files to load:

"n8n": {
  "n8nNodesApiVersion": 1,
  "credentials": [
    "dist/credentials/StatusPageApi.credentials.js"
  ],
  "nodes": [
    "dist/nodes/StatusPage/StatusPage.node.js"
  ]
}

The paths point at compiled JavaScript under dist, which is why a build step always comes before loading the node anywhere.

n8n-node dev, lint and build

n8n-node dev compiles the project, starts a local n8n on localhost:5678 with the node loaded and rebuilds on every file change. That's the whole inner loop. Open the editor, search for the node by its display name (not the package name), drop it on a canvas, run it. n8n-node lint runs the community node linter and n8n-node lint --fix repairs what it can, and you want a clean run before publishing because the verification review runs the same rules. n8n-node build compiles and copies the icon and other assets into dist.

One small thing I noticed while writing this: the declarative tutorial page on docs.n8n.io still says Node 18.17 and still walks through npm link into ~/.n8n/custom, while the environment page says 22.22.0 and the tool page says use n8n-node dev. The CLI page is the newer one. The npm link route still works if you prefer a plain n8n start, it's just no longer the documented default.

Load a private node into Docker with N8N_CUSTOM_EXTENSIONS

Nodes you don't intend to publish load from a folder. The default is .n8n/custom under the home of the user running n8n, which inside the official image is /home/node/.n8n/custom. N8N_CUSTOM_EXTENSIONS adds more folders, semicolon-separated, and the docs' own example is export N8N_CUSTOM_EXTENSIONS="/home/jim/n8n/custom-nodes;/data/n8n/nodes". In compose that becomes a bind mount and one environment line:

services:
  n8n:
    image: n8nio/n8n:2.38.5
    restart: unless-stopped
    environment:
      - N8N_CUSTOM_EXTENSIONS=/home/node/custom-nodes
    volumes:
      - n8n_data:/home/node/.n8n
      - ./custom-nodes/n8n-nodes-statuspage:/home/node/custom-nodes/n8n-nodes-statuspage:ro

volumes:
  n8n_data:

Copy the built package (with its dist folder and package.json) into ./custom-nodes/n8n-nodes-statuspage on the server, then docker compose up -d. On a box set up the way the Ubuntu 24.04 n8n install guide does it, the compose file already has the n8n_data volume and you're adding two lines. n8n reads the folder at startup, so every new build means a container restart, which is the reason n8n-node dev on your laptop is the place to iterate and the VPS is where the finished build lands. The mount is read-only on purpose; nothing in n8n needs to write there.

Custom node code runs inside the main n8n process. It isn't sandboxed on a task runner the way Code node JavaScript is in 2.x, so a bug in your execute() can take the instance down with it and a dependency you pull in has the same access n8n has. Treat a folder-loaded node with the same suspicion as a community package from npm; the flags for locking those down are in the n8n security best practices article.

Publish to npm and install from Settings > Community Nodes

Package names have to start with n8n-nodes- or be scoped as @yourscope/n8n-nodes-. n8n-node release wraps release-it: it builds, lints, updates the changelog, tags the commit, creates a GitHub release and publishes to npm in one go. I run it from a GitHub Actions job rather than my laptop so the provenance attestation comes for free, which matters for the next section; the n8n CI/CD guide has a workflow you can adapt for this. Either way you need an npm login and a clean git tree before running it.

Installing on an instance: an Owner or Admin opens Settings, then Community Nodes, then Install, types the package name with an optional version or dist-tag ([email protected]; dist-tags resolve once at install time), ticks "I understand the risks of installing unverified code from a public source" and installs. The GUI installation page is short. For instances managed from a compose file, N8N_COMMUNITY_PACKAGES_MANAGED_BY_ENV=true with a JSON list in N8N_COMMUNITY_PACKAGES installs the same packages on every start and hides the install button.

Queue mode: workers need the package too

The UI installer puts the package on the main instance. Workers in queue mode run the nodes, so they need it as well, and the docs list queue mode as a case for manual installation: npm i n8n-nodes-statuspage inside ~/.n8n/nodes on each worker. If your workers come from the same compose file as the queue mode setup, the env-managed route above is cleaner: one shared env file and every worker container installs the same packages on start.

Verified community nodes: the rules since May 2026

Verified nodes are the ones n8n has reviewed. They install from the nodes panel with no risk checkbox, and they're the only community nodes available on n8n Cloud. The requirements on the submission page: no runtime dependencies, the n8n-nodes-* naming, built and tested with the n8n-node tool, submitted through the n8n Creator Portal and since May 1, 2026, "nodes submitted for verification must be published using GitHub Actions with a provenance statement." That last line is why the release job belongs in Actions.

The no-dependencies rule is the one that bites. A declarative node has none by construction, which is one more reason to stay declarative. I haven't submitted a node that needs a compiled native module, so how the review treats a package that can't avoid one is something I haven't seen, and my reading of the rule is that it's rejected.

When the HTTP Request node is enough

Most APIs don't need a node. The HTTP Request node with a generic credential (header auth, query auth, OAuth2) covers any REST endpoint, pagination included, and a Set node after it renames fields. Build a node when the same API shows up in five workflows and the people building them shouldn't have to know its URL structure, when the credential needs a custom test or refresh flow or when you're shipping the integration to other teams or customers. For a one-off internal call, the twenty minutes in HTTP Request beats the two hours in a node project, and you can always graduate it later. Our own WHMCS automations ran on HTTP Request nodes for a year before one of them was worth turning into a node.

Debugging a custom node

Under n8n-node dev, console.log from a programmatic node's execute() prints in the terminal running the command, and a declarative node's request can be inspected by setting N8N_LOG_LEVEL=debug on that local instance to see the outgoing HTTP calls. On the VPS the same variable in the compose environment plus docker compose logs -f n8n shows why a folder-loaded node failed to register, which is nearly always a path in the package.json n8n block that doesn't match a file in dist. The other frequent cause is a build with an old 'main' string in inputs, which the linter catches before you get that far.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.