TL;DR: Scaffold a new n8n custom node with npm create @n8n/node (it pulls in @n8n/node-cli, which bundles n8n for local dev so you do not install n8n globally). Default to the declarative style for any REST API: you describe requestDefaults and per-operation routing, and n8n builds and sends the HTTP request for you. Drop to the programmatic style (an execute() method) only when you need GraphQL, external npm dependencies, or to transform incoming data. Develop with npm run dev for hot reload, then publish to npm with the n8n-community-node-package keyword so users can install it from Settings > Community Nodes. Requires Node.js v22+.
A custom node is the right move when the HTTP Request node stops being enough: you are calling the same internal API across ten workflows, you want a typed interface with dropdowns instead of raw JSON bodies, or you need to ship an integration your whole team (or the wider community) can install in one click. If you just need a one-off transformation, stay in the Code node and skip this entirely. We cover that path in the n8n Code node JavaScript and Python examples.
This is the build path that works in 2026, with the current CLI-based tooling. The older "clone the starter and run gulp" instructions you will find in stale blog posts have been replaced.
Declarative or programmatic: pick before you write a line
Default to declarative. It is the official recommendation for HTTP API integrations, it is mostly JSON configuration rather than imperative code, and that means less boilerplate and fewer places to introduce bugs. The framework reads your configuration and constructs the request itself, so you are not hand-writing fetch logic, pagination loops, or error mapping.
Use the programmatic style only when declarative genuinely cannot express what you need. Per the n8n docs, that is three cases:
-
The API is not REST. GraphQL endpoints need a real request body builder, which means code.
-
Your node depends on an external npm package. Declarative routing has no place to import and call a library.
-
You need to transform incoming data before or after the request, beyond simple field mapping.
The mechanical difference is one method. Programmatic nodes implement execute(), which reads the input items and node parameters, builds the request, and returns the output. Declarative nodes have no execute() at all; you put a routing key on each operation and the runtime does the request. If you find yourself reaching for execute() to call one clean REST endpoint, stop and reconsider, you almost certainly want declarative.
Scaffold the package with the CLI
Forget manual setup. The fastest correct path in 2026 is the interactive generator:
npm create @n8n/node
This scaffolds a complete node package using @n8n/node-cli. The CLI is added as a dev dependency and bundles n8n for local development, so you do not install n8n globally. You need Node.js v22 or higher and git installed first.
If you would rather learn from working examples, generate a repo from the official n8n-nodes-starter template instead and clone it:
git clone https://github.com/<your-org>/<your-repo>.git
cd <your-repo>
npm install
The starter ships two reference nodes worth reading before you write your own: an Example node that shows the basic programmatic structure with a custom execute(), and a GithubIssues node built in the declarative style with multiple resources, multiple operations, two auth methods, and dynamic dropdowns. The GitHub Issues node is the one to copy for a real REST integration.
The files that matter
A node package has a predictable shape. Three things define it.
package.json: the n8n manifest
This is where n8n discovers your node. Two hard rules: the package name must start with n8n-nodes-, and the keywords array must include n8n-community-node-package (this is the tag npm and n8n use to find community packages). The n8n block points at the compiled output:
{
"name": "n8n-nodes-myservice",
"version": "0.1.0",
"keywords": ["n8n-community-node-package"],
"scripts": {
"build": "n8n-node build",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"release": "n8n-node release"
},
"n8n": {
"n8nNodesApiVersion": 1,
"strict": true,
"credentials": ["dist/credentials/MyServiceApi.credentials.js"],
"nodes": ["dist/nodes/MyService/MyService.node.js"]
},
"peerDependencies": {
"n8n-workflow": "*"
}
}
The paths in nodes and credentials point to compiled .js files in dist/, not your .ts source. That trips people up constantly: you author MyService.node.ts, the build emits MyService.node.js, and the manifest references the .js. If your node does not show up, this mismatch is the first thing to check.
The .node.ts file
The node itself is a TypeScript class implementing INodeType. The whole node is configured through one description object typed as INodeTypeDescription. Here is the actual Example node from the starter, lightly trimmed, so you can see the real field names rather than a paraphrase:
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
export class Example implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example',
name: 'example',
icon: { light: 'file:example.svg', dark: 'file:example.dark.svg' },
group: ['input'],
version: 1,
description: 'Basic Example Node',
defaults: {
name: 'Example',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
usableAsTool: true,
properties: [
{
displayName: 'My String',
name: 'myString',
type: 'string',
default: '',
placeholder: 'Placeholder value',
description: 'The description text',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const myString = this.getNodeParameter('myString', i, '') as string;
items[i].json.myString = myString;
}
return [items];
}
}
The field names to internalize: displayName is what the user sees in the canvas; name is the internal camelCase identifier; group categorizes the node; version lets you ship breaking changes without breaking existing workflows; properties is the array of fields rendered in the node UI; and each property has its own displayName, name, type, and default. Note usableAsTool: true, which exposes the node to AI agent workflows, a 2026 default worth keeping on.
The declarative version: no execute(), just routing
For a REST API you delete the execute() method entirely and add requestDefaults plus routing. A minimal declarative node hitting one endpoint looks like this:
description: INodeTypeDescription = {
displayName: 'My Service',
name: 'myService',
group: ['transform'],
version: 1,
description: 'Talk to My Service API',
defaults: { name: 'My Service' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: 'myServiceApi', required: true }],
requestDefaults: {
baseURL: 'https://api.myservice.com',
headers: { 'Content-Type': 'application/json' },
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [{ name: 'Item', value: 'item' }],
default: 'item',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['item'] } },
options: [
{
name: 'Get',
value: 'get',
action: 'Get an item',
routing: { request: { method: 'GET', url: '=/items/{{$parameter.itemId}}' } },
},
],
default: 'get',
},
{
displayName: 'Item ID',
name: 'itemId',
type: 'string',
default: '',
displayOptions: { show: { resource: ['item'], operation: ['get'] } },
},
],
};
The runtime merges requestDefaults (the baseURL and shared headers) with the operation's routing.request (the method and url) and the user's parameter values, then sends the HTTP request. The routing.request object also accepts qs for query strings and send for body configuration. You wrote no fetch call, no response parsing, no error handling. That is the entire reason to prefer declarative.
A credentials file (MyServiceApi.credentials.ts) is a separate class that defines the auth fields and how they attach to requests, and the node references it by name in the credentials array. Keep auth in its own file so multiple nodes can share one credential type.
Test locally with hot reload
There is no npm link dance and no manual ~/.n8n/custom copying with the current CLI. From inside your package, run:
npm run dev
That runs n8n-node dev, which builds your node in watch mode, starts a local n8n instance with your node loaded (usually at http://localhost:5678), and rebuilds automatically as you edit. Drop the node onto a canvas and test it against a real workflow. The legacy ~/.n8n/custom directory still works for manually placing a built package, but for active development the CLI's watch-and-reload loop is faster and is what you should use.
Lint as you go with npm run lint (and npm run lint -- --fix to auto-fix). The linter enforces n8n's node conventions and catches the structural mistakes that cause a node to silently fail to load.
If your custom node needs to run Python rather than call an API, that is a different problem, and a node is usually the wrong tool for it. See how to run Python in n8n for when a Code node or microservice beats a custom node.
Publish it as a community node
Once it works locally, publishing is standard npm publishing with one n8n-specific requirement: the n8n-community-node-package keyword in package.json is what makes your package discoverable as a community node. The CLI gives you npm run release (which runs n8n-node release) to handle the publish flow.
After your package is on npm, anyone can install it from their n8n instance under Settings > Community Nodes by entering the npm package name. n8n pulls it from the registry and loads it into their instance. If you want the node to appear in n8n's vetted listing and be installable without the "unverified" warning, submit it to n8n's verified community nodes program, which adds a review step on top of the npm publish.
Version discipline matters more for a published node than for an internal one. The version field in your node description exists so you can change behavior without breaking the workflows other people have already built on your node. Bump it for breaking changes and keep the old version handling intact rather than mutating v1 in place.
When not to build a node
Building a node is overkill for most automation problems, and recognizing that saves you days. If you call an API in one or two workflows, the HTTP Request node is faster to set up and easier to maintain than a custom node. If you need a quick data transform, the Code node wins. Build a custom node when the integration is reused across many workflows, when you want a polished typed interface for non-technical teammates, or when you intend to share it publicly. The maintenance cost of a node, keeping it building against n8n updates, versioning it, supporting installs, is real, and it only pays off at reuse.
If you would rather not own that maintenance, or you want a node built right the first time with proper credential handling and error mapping, that is the kind of work we do at n8n Logic. Either way, start with the CLI scaffold and the declarative style, and you will have a working node in an afternoon.
FAQ
What is the difference between a custom node and a community node?
They describe the same artifact at different stages. A custom node is any node you build yourself for your own n8n instance. It becomes a community node once you publish it to npm with the n8n-community-node-package keyword so other people can install it from Settings > Community Nodes.
Should I use declarative or programmatic style for my n8n node?
Default to declarative for any REST API. It is JSON configuration, has less boilerplate, and the framework builds the HTTP request for you. Use programmatic (an execute() method) only when you need GraphQL, an external npm dependency, or to transform incoming data, the three cases declarative cannot handle.
How do I test an n8n custom node locally?
Run npm run dev inside your node package. The @n8n/node-cli starts a local n8n instance with your node loaded and hot reload enabled, usually at http://localhost:5678. The CLI bundles n8n, so you do not need to install n8n globally or manually copy files into ~/.n8n/custom.
What Node.js version do I need to build n8n nodes in 2026?
Node.js v22 or higher, plus git. The @n8n/node-cli (which bundles n8n for local development) is installed as a dev dependency when you run npm install, so there is no separate global n8n install required.
Why does my custom node not show up in n8n?
The most common cause is a path mismatch in package.json. The n8n.nodes and n8n.credentials arrays must point to the compiled .js files in dist/, not your .ts source. Also confirm the package name starts with n8n-nodes- and that you have run a build. Run npm run lint to catch structural issues that block loading.
Can a custom node be used by AI agents in n8n?
Yes. Set usableAsTool: true in the node description and the node becomes available as a tool inside AI agent workflows. The starter's example node ships with this enabled, and it is a sensible default for most action nodes in 2026.