Building n8n Custom Nodes: The Guide I Wish I Had
n8n·By Islam Gamal··26 min read

Building n8n Custom Nodes: The Guide I Wish I Had

Declarative vs programmatic nodes, credentials, pagination, binary data, and the packaging steps that turn a folder of TypeScript into an installable community node.

Islam Gamal
Islam Gamal
AI · Data · Automation Engineer
TL;DR

Custom nodes are how n8n stops being a limited toy and becomes your team's automation runtime. Start declarative, drop to programmatic when you need pagination or binary data, package as a community node, and version it like any other library.

Key takeaways
  • Declarative nodes cover most REST APIs with no TypeScript logic at all.
  • Programmatic nodes are required for pagination loops, binary data, and non-trivial auth flows.
  • Credentials are a separate class — test them with the built-in credentialTest field.
  • Publish as an npm package prefixed with n8n-nodes- and install via the community-nodes UI.
  • Semver your nodes; breaking changes bump major and require a migration note in the README.

Declarative vs programmatic — pick correctly

n8n supports two node styles. Declarative nodes are JSON-ish descriptions of an HTTP request; n8n handles the fetch, auth, and error mapping. Programmatic nodes give you an execute() function where you write TypeScript directly.

Rule of thumb: if your integration is one HTTP call per operation and returns JSON, go declarative — you'll finish in an hour and the node will keep working when n8n upgrades. If you need pagination loops, streaming, binary data, or conditional branching, go programmatic.

Pro tip: You can mix: declarative resources for the boring 80% of operations, one programmatic operation for the tricky one.

The minimum declarative node

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

export class Acme implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Acme',
    name: 'acme',
    icon: 'file:acme.svg',
    group: ['transform'],
    version: 1,
    description: 'Interact with the Acme API',
    defaults: { name: 'Acme' },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [{ name: 'acmeApi', required: true }],
    requestDefaults: {
      baseURL: 'https://api.acme.io/v1',
      headers: { 'Content-Type': 'application/json' },
    },
    properties: [
      { displayName: 'Resource', name: 'resource', type: 'options',
        options: [{ name: 'Customer', value: 'customer' }], default: 'customer', noDataExpression: true },
      { displayName: 'Operation', name: 'operation', type: 'options',
        displayOptions: { show: { resource: ['customer'] } },
        options: [{ name: 'Get', value: 'get', action: 'Get a customer',
          routing: { request: { method: 'GET', url: '=/customers/{{$parameter.id}}' } } }],
        default: 'get', noDataExpression: true },
      { displayName: 'Customer ID', name: 'id', type: 'string', default: '',
        displayOptions: { show: { resource: ['customer'], operation: ['get'] } } },
    ],
  };
}

That's a working node. No execute(), no manual fetch, no error handling — n8n does all of it from the routing declaration.

Credentials as first-class objects

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

export class AcmeApi implements ICredentialType {
  name = 'acmeApi';
  displayName = 'Acme API';
  properties: INodeProperties[] = [
    { displayName: 'API Key', name: 'apiKey', type: 'string', typeOptions: { password: true }, default: '' },
  ];
  authenticate = {
    type: 'generic' as const,
    properties: { headers: { Authorization: '=Bearer {{$credentials.apiKey}}' } },
  };
  test = {
    request: { baseURL: 'https://api.acme.io/v1', url: '/whoami' },
  };
}

The test block powers the 'Test connection' button in the UI. Ship it — users will thank you the first time they mistype a key.

Pagination — the pattern for programmatic nodes

async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
  const out: INodeExecutionData[] = [];
  let cursor: string | undefined;
  do {
    const res = await this.helpers.httpRequestWithAuthentication.call(this, 'acmeApi', {
      method: 'GET',
      url: 'https://api.acme.io/v1/orders',
      qs: { cursor, limit: 200 },
      json: true,
    });
    for (const item of res.data) out.push({ json: item });
    cursor = res.next_cursor;
  } while (cursor);
  return [out];
}

Always cap the loop (e.g. while (cursor && out.length < 50_000)) and expose a limit parameter. Runaway pagination is the most common way custom nodes take down an n8n instance.

Binary data without exploding memory

If your node fetches or produces files, use n8n's binary helpers instead of loading everything into a JS Buffer:

const stream = await this.helpers.httpRequest({ method: 'GET', url, encoding: 'stream' });
const binary = await this.helpers.prepareBinaryData(stream, fileName, mimeType);
return [[{ json: {}, binary: { data: binary } }]];

This streams to n8n's configured binary storage (filesystem or S3) instead of the workflow's JSON payload. On queue-mode installs, this is the difference between a working node and OOM-killed workers.

Packaging and publishing

  1. Package name must start with n8n-nodes- (e.g. n8n-nodes-acme).
  2. Set n8n field in package.json pointing to dist/nodes and dist/credentials.
  3. Add keyword n8n-community-node-package — this is what the installer searches for.
  4. Run npm run build (tsc + copy icons), then npm publish --access public.
  5. In n8n Settings → Community Nodes → Install, paste the package name.

Version aggressively. If you change a parameter name or an operation's response shape, bump major and document the migration. n8n users cannot 'roll back' a workflow the way developers roll back code.

#n8n#TypeScript#Automation#Community Nodes

Frequently asked

Can I keep custom nodes private?

Yes — publish to a private npm registry (Verdaccio, GitHub Packages) and configure the n8n instance to install from it. Community Nodes UI supports scoped and private packages.

Do I need to write tests?

For declarative nodes, spot-check in the UI is usually enough. For programmatic nodes with pagination or state, add Jest tests that mock the HTTP layer — they catch regressions on n8n version bumps.

Can custom nodes call other nodes?

Not directly. Compose in the workflow instead. If you need reusability across workflows, build a sub-workflow and call it from a Workflow node.

Building something similar?

I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.

Work with me
Islam Gamal
Written by

Islam Gamal

AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.

More from Islam Gamal

Browse every article by Islam Gamal on the author page.