background-shape
Writing Custom n8n Nodes in TypeScript, A Step by Step Tutorial
August 6, 2025 · 11 min read · by Muhammad Amal programming

TL;DR — The n8n custom node SDK in 1.78 is finally pleasant. TypeScript 5.6 strict mode catches most schema bugs at compile time. Use the declarative routing API for REST-shaped APIs and the imperative execute for anything else. Ship as a private npm package with n8n.nodes in package.json.

I’ve shipped about a dozen custom n8n nodes for internal platforms over the last two years, and the SDK in August 2025 is genuinely good. The two big shifts since 2023 are the declarative routing API and the stable credentials test interface. Together they let you write a real production node in a couple of hundred lines, with proper credential validation and pagination, without touching the imperative execute path at all.

This tutorial walks through building a complete node for a fictional internal billing service. We’ll do credentials, declarative routing, an imperative fallback for the one operation that doesn’t fit, pagination, error mapping, and packaging. The end state is a node you could publish to your private registry and install on a queue-mode cluster, the kind described in the advanced n8n architecture article.

Opinions up front. Don’t write a custom node if HTTP Request will do. Don’t write a custom node to wrap a Code node. Do write a custom node when the same API surface is used in five workflows or when credentials need to be reused with rotation. Custom nodes are for shared infrastructure, not one-off scripts.

1. Scaffolding the Project

The official n8n-nodes-starter template is the right starting point but it ships with a lot of demo cruft. I prefer a minimal layout.

billing-node/
├── package.json
├── tsconfig.json
├── credentials/
│   └── BillingApi.credentials.ts
├── nodes/
│   └── Billing/
│       ├── Billing.node.ts
│       ├── Billing.node.json
│       ├── billing.svg
│       └── descriptions/
│           ├── InvoiceDescription.ts
│           └── CustomerDescription.ts
└── gulpfile.js

The package.json is what n8n actually reads to discover the node. The n8n block is required.

{
  "name": "@acme/n8n-nodes-billing",
  "version": "0.1.0",
  "description": "n8n node for the Acme billing API",
  "main": "index.js",
  "scripts": {
    "build": "tsc && gulp build:icons",
    "dev": "tsc --watch",
    "lint": "eslint nodes credentials --ext .ts"
  },
  "files": ["dist"],
  "n8n": {
    "n8nNodesApiVersion": 1,
    "credentials": ["dist/credentials/BillingApi.credentials.js"],
    "nodes": ["dist/nodes/Billing/Billing.node.js"]
  },
  "devDependencies": {
    "@types/node": "^22.5.0",
    "n8n-workflow": "1.78.0",
    "typescript": "5.6.3",
    "gulp": "^5.0.0"
  },
  "peerDependencies": {
    "n8n-workflow": "1.78.0"
  }
}

n8n-workflow must be a peer dependency, not a regular dependency. The host n8n instance provides it, and a version mismatch in node_modules is one of the most painful debug sessions you can have.

The tsconfig.json should be strict.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": ".",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "declaration": true,
    "sourceMap": true,
    "skipLibCheck": true
  },
  "include": ["credentials/**/*", "nodes/**/*"]
}

n8n runs on Node.js 22 LTS in 1.78, so ES2022 as a compile target is safe.

2. Defining the Credential

Credentials are their own class. They define the fields the user fills in, an optional authenticate block that injects auth into requests, and an optional test block that validates the credential when the user clicks “Save”.

// credentials/BillingApi.credentials.ts
import type {
  ICredentialType,
  INodeProperties,
  IAuthenticateGeneric,
  ICredentialTestRequest,
} from 'n8n-workflow';

export class BillingApi implements ICredentialType {
  name = 'billingApi';
  displayName = 'Acme Billing API';
  documentationUrl = 'https://billing.acme.internal/docs';

  properties: INodeProperties[] = [
    {
      displayName: 'Base URL',
      name: 'baseUrl',
      type: 'string',
      default: 'https://billing.acme.internal',
    },
    {
      displayName: 'API Key',
      name: 'apiKey',
      type: 'string',
      typeOptions: { password: true },
      default: '',
      required: true,
    },
  ];

  authenticate: IAuthenticateGeneric = {
    type: 'generic',
    properties: {
      headers: {
        Authorization: '=Bearer {{$credentials.apiKey}}',
        'X-Acme-Client': 'n8n',
      },
    },
  };

  test: ICredentialTestRequest = {
    request: {
      baseURL: '={{$credentials.baseUrl}}',
      url: '/v1/whoami',
      method: 'GET',
    },
  };
}

The = prefix on string values turns them into n8n expressions. {{$credentials.apiKey}} is resolved at request time from the stored credential. Mark secrets with typeOptions: { password: true } so the editor masks them.

The test block is what gets called when the user clicks the “Test” button. Pick an endpoint that’s cheap, idempotent, and returns 200 only when the credential is actually valid. /whoami or /me style endpoints are ideal.

3. The Node, Declarative Style

The declarative API in n8n 1.78 covers most REST-shaped APIs without writing an execute method. You describe the operations as data, and n8n’s HTTP routing layer handles the calls.

// nodes/Billing/Billing.node.ts
import type {
  INodeType,
  INodeTypeDescription,
} from 'n8n-workflow';
import { invoiceOperations, invoiceFields } from './descriptions/InvoiceDescription';
import { customerOperations, customerFields } from './descriptions/CustomerDescription';

export class Billing implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Acme Billing',
    name: 'billing',
    icon: 'file:billing.svg',
    group: ['transform'],
    version: 1,
    subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
    description: 'Interact with the Acme billing API',
    defaults: { name: 'Billing' },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [{ name: 'billingApi', required: true }],
    requestDefaults: {
      baseURL: '={{$credentials.baseUrl}}',
      headers: { 'Content-Type': 'application/json' },
    },
    properties: [
      {
        displayName: 'Resource',
        name: 'resource',
        type: 'options',
        noDataExpression: true,
        options: [
          { name: 'Invoice', value: 'invoice' },
          { name: 'Customer', value: 'customer' },
        ],
        default: 'invoice',
      },
      ...invoiceOperations,
      ...invoiceFields,
      ...customerOperations,
      ...customerFields,
    ],
  };
}

The requestDefaults block sets the base URL and headers for every routed request. displayOptions on each operation controls visibility based on the selected resource. This is the part that the imperative style spends three hundred lines of switch statements doing.

Now the invoice description.

// nodes/Billing/descriptions/InvoiceDescription.ts
import type { INodeProperties } from 'n8n-workflow';

export const invoiceOperations: INodeProperties[] = [
  {
    displayName: 'Operation',
    name: 'operation',
    type: 'options',
    noDataExpression: true,
    displayOptions: { show: { resource: ['invoice'] } },
    options: [
      {
        name: 'Get',
        value: 'get',
        action: 'Get an invoice',
        routing: {
          request: { method: 'GET', url: '=/v1/invoices/{{$parameter.invoiceId}}' },
        },
      },
      {
        name: 'List',
        value: 'list',
        action: 'List invoices',
        routing: {
          request: { method: 'GET', url: '/v1/invoices' },
          send: {
            paginate: true,
          },
          operations: {
            pagination: {
              type: 'generic',
              properties: {
                continue: '={{ $response.body.next_cursor !== null }}',
                request: {
                  qs: { cursor: '={{ $response.body.next_cursor }}' },
                },
              },
            },
          },
          output: {
            postReceive: [
              { type: 'rootProperty', properties: { property: 'data' } },
            ],
          },
        },
      },
      {
        name: 'Create',
        value: 'create',
        action: 'Create an invoice',
        routing: {
          request: { method: 'POST', url: '/v1/invoices' },
        },
      },
    ],
    default: 'list',
  },
];

export const invoiceFields: INodeProperties[] = [
  {
    displayName: 'Invoice ID',
    name: 'invoiceId',
    type: 'string',
    required: true,
    default: '',
    displayOptions: {
      show: { resource: ['invoice'], operation: ['get'] },
    },
  },
  {
    displayName: 'Customer ID',
    name: 'customerId',
    type: 'string',
    required: true,
    default: '',
    displayOptions: {
      show: { resource: ['invoice'], operation: ['create'] },
    },
    routing: {
      send: { type: 'body', property: 'customer_id' },
    },
  },
  {
    displayName: 'Amount Cents',
    name: 'amountCents',
    type: 'number',
    required: true,
    default: 0,
    displayOptions: {
      show: { resource: ['invoice'], operation: ['create'] },
    },
    routing: {
      send: { type: 'body', property: 'amount_cents' },
    },
  },
];

The pagination block is the part that’s worth pausing on. continue is an expression evaluated against the response body. As long as it returns truthy, the routing layer issues another request with the merged qs parameters. n8n handles the loop and concatenates the results. postReceive with rootProperty peels off the data envelope so downstream nodes see the items, not the wrapper.

4. The Imperative Escape Hatch

Some operations don’t fit the declarative routing model. File uploads, multi-step flows, anything that needs a Code-node-like transformation. Add an execute method to the node and route to it from a specific operation.

// nodes/Billing/Billing.node.ts (additions)
import type {
  IExecuteFunctions,
  INodeExecutionData,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';

export class Billing implements INodeType {
  // ...description as above

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

    if (resource !== 'invoice' || operation !== 'syncFromLedger') {
      return [items];
    }

    const results: INodeExecutionData[] = [];

    for (let i = 0; i < items.length; i++) {
      const ledgerId = this.getNodeParameter('ledgerId', i) as string;

      try {
        const reconcile = await this.helpers.httpRequestWithAuthentication.call(
          this,
          'billingApi',
          {
            method: 'POST',
            url: '/v1/invoices/reconcile',
            body: { ledger_id: ledgerId },
            json: true,
          },
        );

        const status = await this.helpers.httpRequestWithAuthentication.call(
          this,
          'billingApi',
          {
            method: 'GET',
            url: `/v1/reconciliations/${reconcile.id}`,
          },
        );

        results.push({ json: { ledgerId, ...status } });
      } catch (error) {
        if (this.continueOnFail()) {
          results.push({ json: { error: (error as Error).message }, pairedItem: i });
          continue;
        }
        throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
      }
    }

    return [results];
  }
}

Two things to call out. httpRequestWithAuthentication is the helper that applies the credential’s authenticate block to a request, so you don’t reimplement auth in the imperative path. NodeOperationError is the error class n8n uses to render a useful failure in the editor and the executions view. Wrap raw exceptions in it so users see something readable.

The continueOnFail check honors the node’s “Continue On Fail” setting. Workflows that toggle that on expect errors as data items, not thrown exceptions. Respect it.

5. Local Testing Against a Running n8n

The fastest dev loop is npm link against a local n8n install.

# in the node project
npm install
npm run build
cd dist
npm link

# in a separate n8n install
cd ~/.n8n/custom
npm link @acme/n8n-nodes-billing

# start n8n with the custom dir on the search path
N8N_CUSTOM_EXTENSIONS=~/.n8n/custom n8n start

The editor will show your node on the next reload. Changes require a rebuild and an n8n restart. Use tsc --watch in one terminal and a process supervisor like nodemon on n8n in another.

For unit tests, n8n exposes a n8n-workflow/test helper from 1.74 onwards.

// nodes/Billing/Billing.node.test.ts
import { mockExecute } from 'n8n-workflow/test';
import { Billing } from './Billing.node';

describe('Billing.execute', () => {
  it('reconciles ledger entries', async () => {
    const node = new Billing();
    const result = await mockExecute(node, {
      parameters: { resource: 'invoice', operation: 'syncFromLedger', ledgerId: 'L-1' },
      credentials: { billingApi: { baseUrl: 'https://x', apiKey: 'k' } },
      httpResponses: [
        { json: { id: 'r1' } },
        { json: { id: 'r1', status: 'complete' } },
      ],
    });

    expect(result[0][0].json.status).toBe('complete');
  });
});

mockExecute stubs out the HTTP helpers with the responses you provide. It’s the only way to test imperative nodes without a live API.

6. Packaging and Publishing

Build artifacts go in dist. The package.json files field whitelists what gets published.

npm run build
npm pack --dry-run   # inspect what would ship
npm publish --registry https://npm.internal.acme.com

On the n8n cluster, install via N8N_NODES_INCLUDE for community-style installs, or via the npm install in your container image for self-hosted.

FROM n8nio/n8n:1.78.0
USER root
RUN cd /usr/local/lib/node_modules/n8n && \
    npm install @acme/n8n-nodes-billing@0.1.0 \
    --registry https://npm.internal.acme.com
USER node

For the canonical guide on the node SDK including the routing API options, the n8n creating nodes docs cover every property and helper.

Common Pitfalls

Four mistakes that show up in code review.

Putting n8n-workflow in dependencies instead of peerDependencies. Your node ships its own copy, which conflicts with the host. Instance types fail instanceof checks. Errors are silently swallowed because the error class is a different class. Always peer-dependency.

Forgetting noDataExpression: true on resource and operation dropdowns. Without it, users can wire a previous node’s data into the dropdown via an expression, and the displayOptions show/hide logic stops working because the value isn’t a static string at edit time. The result is a node that looks broken in the editor.

Hardcoding the base URL in requestDefaults instead of reading from credentials. Users with non-default deployments (staging, EU region, on-prem) can’t override the base URL. Always source it from a credential field with ={{$credentials.baseUrl}}.

Returning a single array instead of an array of arrays from execute. The return type is INodeExecutionData[][], one inner array per output port. Returning INodeExecutionData[] compiles in non-strict TypeScript and crashes at runtime with “Cannot read property of undefined”. Strict mode catches it. Turn strict mode on.

Troubleshooting

Three failures you’ll hit.

Node doesn’t appear in the editor after install. Check ~/.n8n/log/n8n.log for “Failed to load node”. The usual cause is a missing path in the n8n.nodes array in package.json, or a TypeScript build that didn’t emit the .js file. Run ls dist/nodes/Billing/Billing.node.js to confirm.

Credential test passes but operations fail with 401. The authenticate block isn’t being applied to the imperative execute path. You used this.helpers.httpRequest instead of this.helpers.httpRequestWithAuthentication. Switch to the latter and pass the credential name explicitly.

Pagination loops forever. The continue expression isn’t going false. Add a defensive && $pageCount < 100 term and log $response.body.next_cursor to see what the API actually returns. Some APIs use null, some use empty string, some omit the field entirely. Test all three.

Wrapping Up

Custom n8n nodes in August 2025 are a small, sharp tool. The declarative routing API handles 90 percent of REST integrations in less code than a Code node. The imperative execute is there when you need it. Credentials are first-class, with proper testing and rotation support. Package as a versioned npm artifact and treat it like any other library your platform team owns.

The next article in this series uses these nodes to orchestrate enterprise data syncs across a Jira, Postgres, and S3 trio. After that we’ll get into the production-grade queue mode setup that this work assumes.