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
AspectDeclarativeProgrammaticBest forSimple REST API wrappersCustom logic, transforms, multi-call flowsHTTP handlingAutomatic via routing keysManual in execute() methodKey propertyrequestDefaults in descriptionasync execute() methodImports neededINodeType, INodeTypeDescription+ IExecuteFunctions, INodeExecutionDataComplexityLower — declare routes, n8n handles requestsHigher — full control over executionPaginationVia operations.pagination configManual loop in apiRequestAllItems helperWhen to chooseAPI maps 1:1 to operations, no transformsNeed 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.
IMPORTANT: Always start by cloning the official n8n-nodes-starter repository. Do NOT scaffold a project from scratch. The starter provides the correct TypeScript config, build tooling, linting, and project structure that n8n expects.
bash
# Clone the official starter — this is always the first step
git clone https://github.com/n8n-io/n8n-nodes-starter.git n8n-nodes-<yourservice>
cd n8n-nodes-<yourservice>
# Remove the starter's git history and reinitialize
rm -rf .git
git init
# Install dependencies
npm install
The starter repo provides:
tsconfig.json with the correct compiler options for n8npackage.json with build scripts ( build, dev, lint, lintfix)n8n object structure in package.json pointing to dist/ outputsAfter cloning, rename/replace the example node and credential files with your own, and update package.json with your package name ( n8n-nodes-<yourservice>), description, and the n8n.nodes / n8n.credentials arrays.
FilePurposenodes/<Name>/<Name>.node.tsMain node class (logic + description)nodes/<Name>/<Name>.node.jsonCodex file (categories, doc links)nodes/<Name>/<name>.svgNode icon (SVG, 60×60px recommended)credentials/<Name>Api.credentials.tsCredential/auth definitionpackage.jsonnpm config with n8n object linking nodes & credentials
Critical rules:
n8n-nodes-MyService → MyService.node.ts)myservice.svg)json
{
"name": "n8n-nodes-myservice",
"version": "0.1.0",
"n8n": {
"n8nNodesApiVersion": 1,
"nodes": [
"dist/nodes/MyService/MyService.node.js"
],
"credentials": [
"dist/credentials/MyServiceApi.credentials.js"
]
}
}
Every node implements INodeType with a description object:
typescript
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
],
};
}
PropertyTypeNotesdisplayName``stringShown in node panelname``stringcamelCase internal ID, unique across all nodesicon``string``'file:icon.svg' — ref to SVG in node foldergroup``string[]``['transform'], ['output'], ['input'], or ['trigger']``version``numberStart at 1, increment for breaking changessubtitle``stringExpression template shown below node namedescription``stringShort one-liner for node paneldefaults``object``{ name: 'Display Name' }``inputs``array``[NodeConnectionType.Main] — empty [] for triggersoutputs``array``[NodeConnectionType.Main]``credentials``array``[{ name: 'credName', required: true }]``properties``INodeProperties[]UI fields — resources, operations, parameters
The standard way to organize a node with multiple API resources:
typescript
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:
noDataExpression: true on resource and operation selectorsaction on each operation option (used for the node action list)create, get, getAll, update, delete, upsertdisplayOptions.show to conditionally display fields per resource/operationAdd requestDefaults to description and routing to each operation. No execute() method.
typescript
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' } },
},
],
};
KeyPurposeExamplerouting.requestHTTP method, URL, headers per operation{ method: 'POST', url: '/items' }``routing.sendMap parameter to body/query{ type: 'body', property: 'name' }``routing.output.postReceiveTransform response[{ type: 'rootProperty', properties: { property: 'data' } }]``routing.operations.paginationAuto-pagination config{ type: 'offset', properties: { ... } }
typescript
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 } }}' } },
],
},
},
Add an async execute() method for full control:
typescript
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];
}
}
MethodPurposethis.getInputData()Get input items arraythis.getNodeParameter(name, index)Read a user-configured parameterthis.getCredentials('credName')Retrieve stored credentialsthis.helpers.returnJsonArray(data)Wrap response as INodeExecutionData[]``this.helpers.constructExecutionMetaData(data, { itemData })Link output to input itemsthis.continueOnFail()Check if user enabled "Continue On Fail"this.helpers.request(options)Make HTTP requestthis.helpers.requestWithAuthentication('credName', options)Authenticated HTTP request
TypeDescriptionCommon typeOptionsstringText inputpassword: true, rows: N (multiline)numberNumeric inputminValue, maxValue, numberPrecision``booleanToggle—optionsSingle-select dropdown—multiOptionsMulti-select dropdown—collection"Add Field" group of optional params—fixedCollectionStructured key-value groupsmultipleValues: true``jsonJSON code editor—dateTimeDate/time picker—colorColor picker—resourceLocatorFind by ID/URL/searchmode: ['list', 'url', 'id']``resourceMapperMap fields to external schema—noticeInfo box (not a parameter)—
typescript
{
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 }));
},
},
};
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>.
ClassUse ForImport FromNodeApiErrorExternal API failuresn8n-workflow``NodeOperationErrorInternal logic errorsn8n-workflowtypescript
// 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 });
See the programmatic style example above for the full pattern. The key structure:
try/catchthis.continueOnFail(), push error data and continueElementConventionExampleNode classPascalCaseMyServiceTrigger classPascalCase + TriggerMyServiceTriggerNode file{Class}.node.ts``MyService.node.tsCredential classPascalCase + ApiMyServiceApiCredential file{Class}.credentials.ts``MyServiceApi.credentials.tsInternal namecamelCasemyServicenpm packagen8n-nodes-{name}``n8n-nodes-myserviceOperation valuescamelCase verbscreate, get, getAll, update, delete
When making breaking changes, version the node instead of modifying in place:
typescript
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.
bash
# 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
bash
npm login
npm publish
After publishing, users install via: Settings → Community Nodes → Install → n8n-nodes-myservice
Ensure your package.json has:
"n8n" object with nodes and credentials arrays pointing to dist/ filesmain and files fields"keywords": ["n8n-community-node-package"]Do:
noDataExpression: true on resource/operation selectorsaction on every operation optionconstructExecutionMetaData with itemData for proper item linkingcontinueOnFail() in every execute loopGenericFunctions.ts for shared API request helpersNodeConnectionType.Main instead of string 'main'typeOptions: { password: true } for secret fields in credentialsreturnAll/ limit pair for list operationsDon't:
.node.json file[] for trigger nodesNodeApiError/ NodeOperationErrorQuick reference for common issues. For the full catalog of 16+ errors with fixes, see references/COMMON_MISTAKES.md.
package.json → n8n.nodes paths point to dist/ (not source)npm run buildnpm link and restart n8nname in your credential class matches the name in the node's description.credentials array (case-sensitive)package.json → n8n.credentialsvalue strings must match exactly (case-sensitive) between the selector and displayOptions.showpostReceive → rootProperty extracts the correct JSON path from the API responseconstructExecutionMetaData with { itemData: { item: i } } wrapping every returnJsonArray call