Skip to main content

Overview

Why custom nodes matter Unlock n8n’s full potential - integrate proprietary APIs, implement custom business logic, and optimize complex workflows into single, reusable components instead of chaining 20 nodes together.
Pre-built nodes work great until you need to integrate your company’s proprietary API or implement custom business logic. Custom nodes change everything - build exactly what you need instead of waiting for official support.

What You’ll Build

Lesson projects Two custom nodes demonstrating progression: HTTP Bin Node for basic structure and API interactions, Data Processor Node for complex transformations and business logic.
  1. HTTP Bin Node - Testing node for learning structure, parameters, and API calls
  2. Data Processor Node - Production-grade transformation node with filtering, aggregation, and data manipulation
You’ll understand the patterns and principles to build any node you can imagine.

Node Architecture Fundamentals

Core structure Every n8n node is a TypeScript class implementing INodeType interface - description object defines the UI, execute function contains business logic.
// Basic node structure
import { INodeType, INodeTypeDescription } from 'n8n-workflow';

export class MyCustomNode implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'My Custom Node',
    name: 'myCustomNode',
    group: ['transform'],
    version: 1,
    description: 'My first custom node',
    defaults: {
      name: 'My Custom Node',
    },
    inputs: ['main'],
    outputs: ['main'],
    properties: []
  };

  async execute(this: IExecuteFunctions) {
    // Node logic here
  }
}
Key components:
  • description - Defines UI appearance, name, category, and configuration options
  • displayName - What users see in the node panel
  • name - Internal identifier (must be unique)
  • group - Node panel category placement
  • inputs/outputs - Connection count the node accepts/produces
  • properties - Configuration fields users can set
  • execute() - Core logic that runs during workflow execution
n8n handles workflow execution, error handling, and data passing - you focus on node-specific logic.

Development Environment Setup

Project initialization Create professional development setup - initialize Node.js project, configure TypeScript for n8n compatibility, and set up package.json with proper n8n configuration.

Step 1: Initialize Your Node Project

# Create project directory
mkdir n8n-nodes-custom && cd n8n-nodes-custom

# Initialize package.json
npm init -y

# Install n8n dependencies
npm install n8n-core n8n-workflow

# Install development dependencies
npm install -D @types/node typescript
The n8n-core and n8n-workflow packages provide interfaces and helper functions. TypeScript gives you intellisense and catches errors before runtime.

Step 2: Configure TypeScript

{
  "compilerOptions": {
    "lib": ["es2020"],
    "target": "es2019",
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
ES2019 target ensures n8n compatibility. CommonJS modules required for n8n’s loading system. Strict flag catches type errors early.

Step 3: Configure Package for n8n

{
  "name": "n8n-nodes-custom",
  "version": "0.1.0",
  "description": "Custom n8n nodes",
  "main": "index.js",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",      // Hot reload during development
    "lint": "eslint src --ext .ts",
    "prepublishOnly": "npm run build"
  },
  "n8n": {
    "n8nNodesApiVersion": 1,
    "nodes": [
      "dist/nodes/HttpBin/HttpBin.node.js",
      "dist/nodes/DataProcessor/DataProcessor.node.js"
    ],
    "credentials": [
      "dist/credentials/CustomApi.credentials.js"
    ]
  },
  "files": [
    "dist"
  ]
}
The n8n section tells n8n where to find your nodes and credentials. When n8n starts up, it loads your custom nodes into the node panel alongside built-in ones.

Building Your First Node

HTTP Bin node project Build a node that makes HTTP requests, handles dynamic parameters, processes responses, and provides user-friendly configuration - perfect for learning core patterns.
Let’s build an HTTP Bin node that interacts with httpbin.org - simple enough to understand but complex enough to teach important patterns.

What This Node Will Do

  • Make HTTP requests (GET, POST, PUT, DELETE)
  • Accept dynamic parameters and request bodies
  • Handle responses and errors gracefully
  • Provide user-friendly configuration
You’ll learn to define UI properties, handle user inputs, make API calls, and process data.

Creating the Node Structure

import {
  IExecuteFunctions,
  INodeExecutionData,
  INodeType,
  INodeTypeDescription,
  NodeOperationError,
} from 'n8n-workflow';
import { OptionsWithUri } from 'request-promise-native';

export class HttpBin implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'HTTP Bin',
    name: 'httpBin',
    icon: 'file:httpbin.svg',
    group: ['input'],
    version: 1,
    subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
    description: 'Interact with httpbin.org for testing',
    defaults: {
      name: 'HTTP Bin',
    },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [],
    properties: [
      {
        displayName: 'Resource',
        name: 'resource',
        type: 'options',
        noDataExpression: true,
        options: [
          { name: 'Request', value: 'request' },
          { name: 'Response', value: 'response' },
          { name: 'Auth', value: 'auth' },
        ],
        default: 'request',
      },
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        displayOptions: {
          show: {
            resource: ['request'],
          },
        },
        options: [
          { name: 'GET', value: 'get' },
          { name: 'POST', value: 'post' },
          { name: 'PUT', value: 'put' },
          { name: 'DELETE', value: 'delete' },
        ],
        default: 'get',
      },
      {
        displayName: 'Endpoint',
        name: 'endpoint',
        type: 'string',
        required: true,
        displayOptions: {
          show: {
            resource: ['request'],
          },
        },
        default: '/anything',
        placeholder: '/anything',
        description: 'The endpoint to call',
      },
      {
        displayName: 'Request Body',
        name: 'body',
        type: 'json',
        displayOptions: {
          show: {
            resource: ['request'],
            operation: ['post', 'put'],
          },
        },
        default: '{}',
        description: 'Body to send with request',
      },
      {
        displayName: 'Query Parameters',
        name: 'queryParams',
        type: 'fixedCollection',
        placeholder: 'Add Query Parameter',
        default: {},
        typeOptions: {
          multipleValues: true,
        },
        options: [
          {
            name: 'parameter',
            displayName: 'Parameter',
            values: [
              {
                displayName: 'Name',
                name: 'name',
                type: 'string',
                default: '',
              },
              {
                displayName: 'Value',
                name: 'value',
                type: 'string',
                default: '',
              },
            ],
          },
        ],
      },
    ],
  };

  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 {
        if (resource === 'request') {
          const endpoint = this.getNodeParameter('endpoint', i) as string;
          const method = operation.toUpperCase();

          const options: OptionsWithUri = {
            method,
            uri: `https://httpbin.org${endpoint}`,
            json: true,
          };

          // Add query parameters
          const queryParams = this.getNodeParameter('queryParams', i, {}) as any;
          if (queryParams.parameter) {
            const qs: any = {};
            for (const param of queryParams.parameter) {
              qs[param.name] = param.value;
            }
            options.qs = qs;
          }

          // Add body for POST/PUT
          if (['POST', 'PUT'].includes(method)) {
            const body = this.getNodeParameter('body', i) as string;
            options.body = JSON.parse(body);
          }

          const response = await this.helpers.request(options);
          returnData.push({ json: response });
        }

        if (resource === 'response') {
          // Generate custom responses
          const statusCode = this.getNodeParameter('statusCode', i, 200) as number;
          const customResponse = {
            status: statusCode,
            timestamp: new Date().toISOString(),
            message: `Generated response with status ${statusCode}`,
          };
          returnData.push({ json: customResponse });
        }

        if (resource === 'auth') {
          // Test authentication
          const authType = this.getNodeParameter('authType', i) as string;
          const options: OptionsWithUri = {
            method: 'GET',
            uri: `https://httpbin.org/${authType}`,
            json: true,
          };

          const response = await this.helpers.request(options);
          returnData.push({ json: response });
        }
      } catch (error) {
        if (this.continueOnFail()) {
          returnData.push({ json: { error: error.message } });
          continue;
        }
        throw new NodeOperationError(this.getNode(), error);
      }
    }

    return [returnData];
  }
}

Step 3: Understanding the Code Structure

Let’s break down what’s happening in this node: The Description Object: This is where you define your node’s UI. Each property in the properties array becomes a field in the node’s configuration panel. The displayOptions property is particularly powerful - it lets you show/hide fields based on other selections, creating a dynamic, context-aware interface. The Execute Method: This is where the magic happens. The execute method:
  1. Gets the input data from previous nodes
  2. Iterates through each item (n8n processes data in batches)
  3. Reads the user’s configuration using getNodeParameter
  4. Makes the HTTP request
  5. Returns the processed data to the next node
n8n handles all the complexity of workflow execution, while you focus on your node’s specific logic.

Step 4: Adding Advanced Features

Now let’s add some advanced features to make our node production-ready. We’ll add:
  • Query parameter support
  • Request body handling for POST/PUT requests
  • Error handling with retry logic
  • Custom authentication options
These features transform our simple node into something you’d actually use in production.

How Do I Build a Production-Grade Node?

The Data Processor node demonstrates advanced patterns including dynamic code execution, mode-based behavior, aggregation logic, and professional error handling for real-world applications.
Now that you understand the basics, let’s build something more sophisticated. The Data Processor node will demonstrate advanced patterns you’ll use in real-world nodes. This node can:
  • Transform data using custom JavaScript expressions
  • Filter items based on conditions
  • Aggregate data (sum, average, group by)
  • Run custom scripts for complex transformations
Create src/nodes/DataProcessor/DataProcessor.node.ts:
import {
  IExecuteFunctions,
  INodeExecutionData,
  INodeType,
  INodeTypeDescription,
} from 'n8n-workflow';

export class DataProcessor implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Data Processor',
    name: 'dataProcessor',
    group: ['transform'],
    version: 1,
    description: 'Process and transform data with custom logic',
    defaults: {
      name: 'Data Processor',
    },
    inputs: ['main'],
    outputs: ['main'],
    properties: [
      {
        displayName: 'Processing Mode',
        name: 'mode',
        type: 'options',
        options: [
          {
            name: 'Transform',
            value: 'transform',
            description: 'Transform data structure',
          },
          {
            name: 'Filter',
            value: 'filter',
            description: 'Filter items based on conditions',
          },
          {
            name: 'Aggregate',
            value: 'aggregate',
            description: 'Aggregate data',
          },
          {
            name: 'Custom Script',
            value: 'script',
            description: 'Run custom JavaScript',
          },
        ],
        default: 'transform',
      },
      {
        displayName: 'Transform Expression',
        name: 'transformExpression',
        type: 'string',
        typeOptions: {
          editor: 'code',
          editorLanguage: 'javascript',
        },
        displayOptions: {
          show: {
            mode: ['transform'],
          },
        },
        default: `// Available variables:
// item - current item
// index - current index
// items - all items

return {
  id: item.json.id,
  name: item.json.name,
  processed: true,
  timestamp: new Date().toISOString()
};`,
        description: 'JavaScript code to transform each item',
      },
      {
        displayName: 'Filter Expression',
        name: 'filterExpression',
        type: 'string',
        typeOptions: {
          editor: 'code',
          editorLanguage: 'javascript',
        },
        displayOptions: {
          show: {
            mode: ['filter'],
          },
        },
        default: `// Return true to keep item, false to filter out
// Available: item, index, items

return item.json.active === true;`,
      },
      {
        displayName: 'Aggregation Type',
        name: 'aggregationType',
        type: 'options',
        displayOptions: {
          show: {
            mode: ['aggregate'],
          },
        },
        options: [
          { name: 'Sum', value: 'sum' },
          { name: 'Average', value: 'average' },
          { name: 'Count', value: 'count' },
          { name: 'Group By', value: 'groupBy' },
          { name: 'Custom', value: 'custom' },
        ],
        default: 'sum',
      },
      {
        displayName: 'Field to Aggregate',
        name: 'aggregateField',
        type: 'string',
        displayOptions: {
          show: {
            mode: ['aggregate'],
            aggregationType: ['sum', 'average'],
          },
        },
        default: 'value',
        description: 'The field to perform aggregation on',
      },
      {
        displayName: 'Group By Field',
        name: 'groupByField',
        type: 'string',
        displayOptions: {
          show: {
            mode: ['aggregate'],
            aggregationType: ['groupBy'],
          },
        },
        default: 'category',
      },
      {
        displayName: 'Custom Script',
        name: 'customScript',
        type: 'string',
        typeOptions: {
          editor: 'code',
          editorLanguage: 'javascript',
        },
        displayOptions: {
          show: {
            mode: ['script'],
          },
        },
        default: `// Full access to items array
// Must return array of items

const processedItems = items.map((item, index) => {
  // Your custom processing logic
  return {
    json: {
      ...item.json,
      processedAt: new Date().toISOString(),
      index: index
    }
  };
});

return processedItems;`,
      },
    ],
  };

  async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
    const items = this.getInputData();
    const mode = this.getNodeParameter('mode', 0) as string;
    let returnData: INodeExecutionData[] = [];

    switch (mode) {
      case 'transform':
        const transformExpression = this.getNodeParameter('transformExpression', 0) as string;
        returnData = items.map((item, index) => {
          const sandbox = {
            item,
            index,
            items,
          };
          const transformFunction = new Function('item', 'index', 'items', transformExpression);
          const result = transformFunction(item, index, items);
          return { json: result };
        });
        break;

      case 'filter':
        const filterExpression = this.getNodeParameter('filterExpression', 0) as string;
        const filterFunction = new Function('item', 'index', 'items', filterExpression);
        returnData = items.filter((item, index) => filterFunction(item, index, items));
        break;

      case 'aggregate':
        const aggregationType = this.getNodeParameter('aggregationType', 0) as string;

        if (aggregationType === 'sum' || aggregationType === 'average') {
          const field = this.getNodeParameter('aggregateField', 0) as string;
          const values = items.map(item => item.json[field] as number).filter(v => v !== undefined);

          const sum = values.reduce((acc, val) => acc + val, 0);
          const result = aggregationType === 'sum' ? sum : sum / values.length;

          returnData = [{
            json: {
              aggregation: aggregationType,
              field,
              result,
              itemCount: values.length,
            }
          }];
        } else if (aggregationType === 'count') {
          returnData = [{
            json: {
              count: items.length,
            }
          }];
        } else if (aggregationType === 'groupBy') {
          const groupField = this.getNodeParameter('groupByField', 0) as string;
          const groups: { [key: string]: any[] } = {};

          items.forEach(item => {
            const key = item.json[groupField] as string;
            if (!groups[key]) groups[key] = [];
            groups[key].push(item.json);
          });

          returnData = Object.entries(groups).map(([key, values]) => ({
            json: {
              [groupField]: key,
              items: values,
              count: values.length,
            }
          }));
        }
        break;

      case 'script':
        const customScript = this.getNodeParameter('customScript', 0) as string;
        const scriptFunction = new Function('items', customScript);
        const result = scriptFunction(items);
        returnData = Array.isArray(result) ? result : [{ json: result }];
        break;
    }

    return [returnData];
  }
}

Understanding the Data Processor Pattern

This node showcases several important patterns: Dynamic Code Execution: The node allows users to write JavaScript expressions that get executed at runtime. This is incredibly powerful but needs careful handling for security. Notice how we use the Function constructor to create sandboxed functions. Mode-Based Behavior: Instead of creating four separate nodes (Transform, Filter, Aggregate, Script), we use a single node with multiple modes. This reduces clutter in the node panel while providing flexibility. Aggregation Logic: The aggregation mode demonstrates how to process entire datasets rather than individual items. This is essential for analytics and reporting workflows.

How Do I Create Custom Credentials?

Custom credentials securely store API keys, secrets, and configuration data with encryption, environment-specific settings, and user-friendly forms for credential management.
Create src/credentials/CustomApi.credentials.ts:
import {
  ICredentialType,
  INodeProperties,
} from 'n8n-workflow';

export class CustomApi implements ICredentialType {
  name = 'customApi';
  displayName = 'Custom API';
  documentationUrl = 'https://docs.example.com/api';
  properties: INodeProperties[] = [
    {
      displayName: 'API Key',
      name: 'apiKey',
      type: 'string',
      typeOptions: {
        password: true,
      },
      default: '',
      required: true,
    },
    {
      displayName: 'API Secret',
      name: 'apiSecret',
      type: 'string',
      typeOptions: {
        password: true,
      },
      default: '',
    },
    {
      displayName: 'Base URL',
      name: 'baseUrl',
      type: 'string',
      default: 'https://api.example.com',
      placeholder: 'https://api.example.com',
    },
    {
      displayName: 'Environment',
      name: 'environment',
      type: 'options',
      options: [
        { name: 'Production', value: 'production' },
        { name: 'Staging', value: 'staging' },
        { name: 'Development', value: 'development' },
      ],
      default: 'production',
    },
  ];
}

How Credentials Work in n8n

When users add credentials:
  1. n8n presents a form based on your properties array
  2. User enters their credentials (API keys, secrets, etc.)
  3. n8n encrypts and stores the credentials securely
  4. Your node can access these credentials at runtime using helper methods
  5. The credentials are never exposed to the workflow or logs
The typeOptions: { password: true } setting ensures sensitive fields are masked in the UI and encrypted in storage. This is critical for security.

How Do I Test My Custom Nodes?

Implement comprehensive testing with unit tests for individual functions, integration tests with real n8n instances, and debugging techniques for troubleshooting development issues.
Testing is crucial for reliable nodes. Let’s set up a comprehensive testing strategy that covers unit tests, integration tests, and debugging techniques.

1. Writing Unit Tests

Create src/nodes/HttpBin/HttpBin.test.ts:
import { HttpBin } from './HttpBin.node';
import { IExecuteFunctions } from 'n8n-workflow';

describe('HttpBin Node', () => {
  let httpBin: HttpBin;
  let executeFunctions: IExecuteFunctions;

  beforeEach(() => {
    httpBin = new HttpBin();
    executeFunctions = {
      getInputData: jest.fn(() => [{ json: { test: 'data' } }]),
      getNodeParameter: jest.fn(),
      helpers: {
        request: jest.fn(),
      },
      continueOnFail: jest.fn(() => false),
      getNode: jest.fn(),
    } as any;
  });

  test('should make GET request', async () => {
    executeFunctions.getNodeParameter = jest.fn()
      .mockReturnValueOnce('request')
      .mockReturnValueOnce('get')
      .mockReturnValueOnce('/get');

    executeFunctions.helpers.request = jest.fn()
      .mockResolvedValue({ success: true });

    const result = await httpBin.execute.call(executeFunctions);

    expect(result).toHaveLength(1);
    expect(result[0]).toHaveLength(1);
    expect(result[0][0].json).toEqual({ success: true });
  });
});

2. Integration Testing with Your Local n8n

Now let’s test your nodes in a real n8n instance:
# Link your node package locally
cd n8n-nodes-custom
npm link

# Link to n8n
cd ~/.n8n/custom
npm link n8n-nodes-custom

# Restart n8n
n8n start

# Your custom nodes should now appear in the node panel

3. Debugging Techniques

When things go wrong (and they will), here’s how to debug effectively:
// Add debug logging to your node
console.log('Debug:', {
  parameter: this.getNodeParameter('mode', 0),
  itemCount: items.length,
});

// Use n8n's logger
this.logger.info('Processing items', { count: items.length });

// Start n8n with debug logging
N8N_LOG_LEVEL=debug n8n start

How Do I Publish My Custom Nodes?

Publish your nodes by preparing package.json with proper metadata, publishing to npm with public access, and enabling installation in n8n instances globally or locally.
Once your nodes are tested and working, you can share them with your team or the community.

1. Prepare for Publication

// package.json
{
  "name": "@yourorg/n8n-nodes-custom",
  "version": "1.0.0",
  "description": "Custom n8n nodes for your organization",
  "keywords": [
    "n8n",
    "n8n-node",
    "workflow",
    "automation"
  ],
  "author": "Your Name",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourorg/n8n-nodes-custom"
  }
}

2. Publish to npm

# Build the project
npm run build

# Login to npm
npm login

# Publish
npm publish --access public

3. Install Your Published Nodes

Users can now install your nodes in their n8n instances:
# Install globally
npm install -g @yourorg/n8n-nodes-custom

# Or in n8n custom directory
cd ~/.n8n/custom
npm install @yourorg/n8n-nodes-custom

What Are the Best Practices for Custom Nodes?

Always implement proper error handling:
try {
  // Your node logic
} catch (error) {
  if (this.continueOnFail()) {
    return [{ json: { error: error.message } }];
  }
  throw new NodeOperationError(this.getNode(), error);
}
  • Process items in batches when possible
  • Use streaming for large files
  • Implement pagination for API calls
  • Cache frequently accessed data
  • Add clear descriptions for all parameters
  • Include examples in parameter placeholders
  • Document credential requirements
  • Provide usage examples
  • Use semantic versioning
  • Maintain backward compatibility
  • Document breaking changes
  • Test migrations thoroughly

What Should I Learn Next?

Progress to advanced node features like webhooks and polling, or learn workflow design patterns to create complex, production-ready automation systems.

Frequently Asked Questions

How long does it take to build a custom node?

A simple node like HTTP Bin takes 2-4 hours for beginners. Complex nodes with advanced features can take days or weeks. Experience significantly reduces development time.

Can I modify existing n8n nodes instead of creating new ones?

Yes, you can fork existing nodes from the n8n repository, modify them, and publish as custom nodes. This is often faster than building from scratch.

What’s the difference between a node and a credential?

Nodes contain business logic and UI for operations. Credentials securely store authentication data (API keys, passwords) that nodes use to connect to services.

How do I handle sensitive data in custom nodes?

Never hardcode sensitive data. Use credentials for API keys, environment variables for configuration, and n8n’s built-in encryption for storing sensitive information.

Can custom nodes access the file system?

Yes, but be cautious. Custom nodes run with the same permissions as n8n. Use n8n’s helper methods when possible and validate all file paths to prevent security issues.

How do I debug custom nodes during development?

Use console.log for basic debugging, n8n’s logger for structured logging, TypeScript for type checking, and n8n’s development mode for hot reloading.

What happens if my custom node has errors?

n8n will catch errors and display them in the workflow execution. Implement proper error handling with try-catch blocks and meaningful error messages for users.

Can I use external npm packages in custom nodes?

Yes, add them to your package.json dependencies. Be mindful of package size and security. Popular packages like axios, lodash, and moment are commonly used.

How do I version my custom nodes?

Use semantic versioning (semver). Increment patch for bug fixes, minor for new features, major for breaking changes. Update the version in your node description.

Can I contribute my nodes to the official n8n repository?

Yes, n8n welcomes community contributions. Follow their contribution guidelines, ensure high code quality, and consider community benefit when proposing new nodes.
I