n8n Community Node Creation
Build production-ready n8n community nodes as npm packages. This skill covers both declarative (REST API wrapping) and programmatic (custom logic) node styles.
References: CREDENTIAL_PATTERNS.md | TRIGGER_PATTERNS.md | EXAMPLES.md | COMMON_MISTAKES.md
Quick Reference: Declarative vs Programmatic
| Aspect | Declarative | Programmatic |
|---|---|---|
| Best for | Simple REST API wrappers | Custom logic, transforms, multi-call flows |
| HTTP handling | Automatic via routing keys | Manual in execute() method |
| Key property | requestDefaults in description | async execute() method |
| Imports needed | INodeType, INodeTypeDescription | + IExecuteFunctions, INodeExecutionData |
| Complexity | Lower — declare routes, n8n handles requests | Higher — full control over execution |
| Pagination | Via operations.pagination config | Manual loop in apiRequestAllItems helper |
| When to choose | API maps 1:1 to operations, no transforms | Need data manipulation, conditional logic, or chained calls |
Decision rule: If every operation is a single HTTP request with simple input→body mapping and the response can be used as-is (or with minor postReceive transforms), use declarative. Otherwise, use programmatic.
Getting Started
1. Scaffold from Starter
# Clone the starter repo git clone https://github.com/n8n-io/n8n-nodes-starter.git n8n-nodes-<yourservice> cd n8n-nodes-<yourservice> npm install
2. Required Files
| File | Purpose |
|---|---|
nodes/<Name>/<Name>.node.ts | Main node class (logic + description) |
nodes/<Name>/<Name>.node.json | Codex file (categories, doc links) |
nodes/<Name>/<name>.svg | Node icon (SVG, 60×60px recommended) |
credentials/<Name>Api.credentials.ts | Credential/auth definition |
package.json | npm config with n8n object linking nodes & credentials |
Critical rules:
- •npm package name must start with
n8n-nodes- - •Class name must match filename (class
MyService→MyService.node.ts) - •Icon filename is lowercase (
myservice.svg)
3. Package.json n8n Config
{
"name": "n8n-nodes-myservice",
"version": "0.1.0",
"n8n": {
"n8nNodesApiVersion": 1,
"nodes": [
"dist/nodes/MyService/MyService.node.js"
],
"credentials": [
"dist/credentials/MyServiceApi.credentials.js"
]
}
}
Node Class Structure
Every node implements INodeType with a description object:
import { INodeType, INodeTypeDescription, NodeConnectionType } from 'n8n-workflow';
export class MyService implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Service',
name: 'myService',
icon: 'file:myservice.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Interact with My Service API',
defaults: { name: 'My Service' },
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{ name: 'myServiceApi', required: true },
],
properties: [
// Resource and operation selectors, then parameter fields
],
};
}
Required Description Properties
| Property | Type | Notes |
|---|---|---|
displayName | string | Shown in node panel |
name | string | camelCase internal ID, unique across all nodes |
icon | string | 'file:icon.svg' — ref to SVG in node folder |
group | string[] | ['transform'], ['output'], ['input'], or ['trigger'] |
version | number | Start at 1, increment for breaking changes |
subtitle | string | Expression template shown below node name |
description | string | Short one-liner for node panel |
defaults | object | { name: 'Display Name' } |
inputs | array | [NodeConnectionType.Main] — empty [] for triggers |
outputs | array | [NodeConnectionType.Main] |
credentials | array | [{ name: 'credName', required: true }] |
properties | INodeProperties[] | UI fields — resources, operations, parameters |
Resource & Operation Pattern
The standard way to organize a node with multiple API resources:
properties: [
// 1. Resource selector
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true, // REQUIRED on resource/operation selectors
options: [
{ name: 'Contact', value: 'contact' },
{ name: 'Deal', value: 'deal' },
],
default: 'contact',
},
// 2. Operation selector (per resource)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['contact'] } },
options: [
{ name: 'Create', value: 'create', action: 'Create a contact' },
{ name: 'Delete', value: 'delete', action: 'Delete a contact' },
{ name: 'Get', value: 'get', action: 'Get a contact' },
{ name: 'Get Many', value: 'getAll', action: 'Get many contacts' },
{ name: 'Update', value: 'update', action: 'Update a contact' },
],
default: 'create',
},
// 3. Operation-specific fields
{
displayName: 'Contact ID',
name: 'contactId',
type: 'string',
required: true,
default: '',
displayOptions: {
show: { resource: ['contact'], operation: ['get', 'update', 'delete'] },
},
description: 'The ID of the contact',
},
// 4. returnAll / limit pair for getAll
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
displayOptions: { show: { resource: ['contact'], operation: ['getAll'] } },
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 50,
typeOptions: { minValue: 1 },
displayOptions: {
show: { resource: ['contact'], operation: ['getAll'], returnAll: [false] },
},
description: 'Max number of results to return',
},
// 5. Additional fields (optional params)
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
options: [
{ displayName: 'Email', name: 'email', type: 'string', default: '' },
{ displayName: 'Phone', name: 'phone', type: 'string', default: '' },
],
},
],
Key rules:
- •Always set
noDataExpression: trueon resource and operation selectors - •Always include
actionon each operation option (used for the node action list) - •Standard operation values:
create,get,getAll,update,delete,upsert - •Use
displayOptions.showto conditionally display fields per resource/operation
Declarative Style
Add requestDefaults to description and routing to each operation. No execute() method.
description: INodeTypeDescription = {
// ...standard properties...
requestDefaults: {
baseURL: 'https://api.myservice.com/v1',
headers: { Accept: 'application/json' },
},
properties: [
{
displayName: 'Operation', name: 'operation', type: 'options',
noDataExpression: true,
options: [
{
name: 'Create', value: 'create', action: 'Create an item',
routing: {
request: { method: 'POST', url: '/items' },
},
},
{
name: 'Get', value: 'get', action: 'Get an item',
routing: {
request: { method: 'GET', url: '=/items/{{$parameter.itemId}}' },
},
},
],
default: 'create',
},
// Map field values to request body/query
{
displayName: 'Name', name: 'name', type: 'string', default: '',
routing: { send: { type: 'body', property: 'name' } },
},
{
displayName: 'Tag', name: 'tag', type: 'string', default: '',
routing: { send: { type: 'query', property: 'tag' } },
},
],
};
Routing Keys
| Key | Purpose | Example |
|---|---|---|
routing.request | HTTP method, URL, headers per operation | { method: 'POST', url: '/items' } |
routing.send | Map parameter to body/query | { type: 'body', property: 'name' } |
routing.output.postReceive | Transform response | [{ type: 'rootProperty', properties: { property: 'data' } }] |
routing.operations.pagination | Auto-pagination config | { type: 'offset', properties: { ... } } |
postReceive Transforms
routing: {
output: {
postReceive: [
{ type: 'rootProperty', properties: { property: 'data' } }, // Extract nested data
{ type: 'filter', properties: { pass: '={{$responseItem.active}}' } },
{ type: 'limit', properties: { maxResults: '={{$parameter.limit}}' } },
{ type: 'set', properties: { value: '={{ { "id": $response.body.id } }}' } },
],
},
},
Programmatic Style
Add an async execute() method for full control:
import {
IExecuteFunctions, INodeExecutionData, INodeType,
INodeTypeDescription, NodeConnectionType,
} from 'n8n-workflow';
export class MyService implements INodeType {
description: INodeTypeDescription = { /* ...same as above, minus requestDefaults/routing... */ };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < items.length; i++) {
try {
let responseData;
if (resource === 'contact') {
if (operation === 'create') {
const email = this.getNodeParameter('email', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
const body: IDataObject = { email, ...additionalFields };
responseData = await myServiceApiRequest.call(this, 'POST', '/contacts', body);
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
if (returnAll) {
responseData = await myServiceApiRequestAllItems.call(
this, 'contacts', 'GET', '/contacts',
);
} else {
const limit = this.getNodeParameter('limit', i) as number;
responseData = await myServiceApiRequest.call(
this, 'GET', '/contacts', {}, { limit },
);
responseData = responseData.contacts;
}
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
}
}
Key Programmatic Helpers
| Method | Purpose |
|---|---|
this.getInputData() | Get input items array |
this.getNodeParameter(name, index) | Read a user-configured parameter |
this.getCredentials('credName') | Retrieve stored credentials |
this.helpers.returnJsonArray(data) | Wrap response as INodeExecutionData[] |
this.helpers.constructExecutionMetaData(data, { itemData }) | Link output to input items |
this.continueOnFail() | Check if user enabled "Continue On Fail" |
this.helpers.request(options) | Make HTTP request |
this.helpers.requestWithAuthentication('credName', options) | Authenticated HTTP request |
UI Property Types
| Type | Description | Common typeOptions |
|---|---|---|
string | Text input | password: true, rows: N (multiline) |
number | Numeric input | minValue, maxValue, numberPrecision |
boolean | Toggle | — |
options | Single-select dropdown | — |
multiOptions | Multi-select dropdown | — |
collection | "Add Field" group of optional params | — |
fixedCollection | Structured key-value groups | multipleValues: true |
json | JSON code editor | — |
dateTime | Date/time picker | — |
color | Color picker | — |
resourceLocator | Find by ID/URL/search | mode: ['list', 'url', 'id'] |
resourceMapper | Map fields to external schema | — |
notice | Info box (not a parameter) | — |
Dynamic Options (Load from API)
{
displayName: 'Channel',
name: 'channelId',
type: 'options',
typeOptions: { loadOptionsMethod: 'getChannels' },
default: '',
}
// In the node class:
methods = {
loadOptions: {
async getChannels(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('myServiceApi');
const channels = await myServiceApiRequest.call(this, 'GET', '/channels');
return channels.map((c: any) => ({ name: c.name, value: c.id }));
},
},
};
Codex File (.node.json)
{
"node": "n8n-nodes-myservice.myService",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Miscellaneous"],
"resources": {
"credentialDocumentation": [{ "url": "" }],
"primaryDocumentation": [{ "url": "" }]
}
}
The node field format is <package-name>.<node-internal-name>.
Error Handling
Two Error Classes
| Class | Use For | Import From |
|---|---|---|
NodeApiError | External API failures | n8n-workflow |
NodeOperationError | Internal logic errors | n8n-workflow |
// In GenericFunctions.ts — wrap API errors:
throw new NodeApiError(this.getNode(), error as JsonObject);
// In execute() — wrap logic errors:
throw new NodeOperationError(this.getNode(), 'Unsupported operation', { itemIndex: i });
continueOnFail Pattern (ALWAYS use in execute loops)
See the programmatic style example above for the full pattern. The key structure:
- •Wrap each item's processing in
try/catch - •On catch: if
this.continueOnFail(), push error data andcontinue - •Otherwise, re-throw the error
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Node class | PascalCase | MyService |
| Trigger class | PascalCase + Trigger | MyServiceTrigger |
| Node file | {Class}.node.ts | MyService.node.ts |
| Credential class | PascalCase + Api | MyServiceApi |
| Credential file | {Class}.credentials.ts | MyServiceApi.credentials.ts |
| Internal name | camelCase | myService |
| npm package | n8n-nodes-{name} | n8n-nodes-myservice |
| Operation values | camelCase verbs | create, get, getAll, update, delete |
Versioning Nodes
When making breaking changes, version the node instead of modifying in place:
import { INodeTypeBaseDescription, INodeTypeDescription, VersionedNodeType } from 'n8n-workflow';
export class MyService extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'My Service',
name: 'myService',
icon: 'file:myservice.svg',
group: ['transform'],
defaultVersion: 2,
description: 'Interact with My Service',
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new MyServiceV1(baseDescription),
2: new MyServiceV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
Each version is a separate class with its own full INodeTypeDescription. Place in V1/ and V2/ subdirectories.
Testing & Publishing
Local Testing
# Build the node package npm run build # Link it into your n8n installation npm link cd ~/.n8n npm link n8n-nodes-myservice # Restart n8n — your node appears in the editor n8n start
Publishing to npm
npm login npm publish
After publishing, users install via: Settings → Community Nodes → Install → n8n-nodes-myservice
Ensure your package.json has:
- •
"n8n"object withnodesandcredentialsarrays pointing todist/files - •Correct
mainandfilesfields - •
"keywords": ["n8n-community-node-package"]
Best Practices
Do:
- •Use
noDataExpression: trueon resource/operation selectors - •Include
actionon every operation option - •Use
constructExecutionMetaDatawithitemDatafor proper item linking - •Implement
continueOnFail()in every execute loop - •Create
GenericFunctions.tsfor shared API request helpers - •Use
NodeConnectionType.Maininstead of string'main' - •Use
typeOptions: { password: true }for secret fields in credentials - •Use
returnAll/limitpair for list operations
Don't:
- •Modify v1 when adding features — create v2 instead
- •Forget the codex
.node.jsonfile - •Use inputs other than
[]for trigger nodes - •Hard-code API base URLs in execute — use credentials or requestDefaults
- •Skip error wrapping — always use
NodeApiError/NodeOperationError - •Create large monolithic node files — split descriptions into separate files per resource
Related Files
- •CREDENTIAL_PATTERNS.md — API key, OAuth2, and custom auth patterns
- •TRIGGER_PATTERNS.md — Webhook, poll, and event trigger implementations
- •EXAMPLES.md — Complete working node examples and helper patterns
- •COMMON_MISTAKES.md — Error catalog with fixes