Build a Custom n8n Node with TypeScript: Step-by-Step 2026

Build, test, and publish a custom n8n node in TypeScript. Covers project setup, declarative UI, credentials, and local testing in under 30 min.

Problem: n8n Has No Node for Your API

n8n ships 400+ integrations, but your internal API, niche SaaS, or custom data source isn't one of them. The HTTP Request node works for one-off calls — but it's not reusable, not type-safe, and doesn't show up cleanly in the UI for teammates.

Building a custom node solves all three.

You'll learn:

  • How to scaffold a typed n8n node package with the official CLI
  • How to wire up credentials, inputs, and API calls with the declarative style
  • How to load and test the node in a local n8n instance without publishing to npm

Time: 30 min | Difficulty: Intermediate


Why Custom Nodes Instead of HTTP Request

The HTTP Request node is fine for exploration. Custom nodes are better when:

  • Reuse — the same API call appears in 5+ workflows
  • Credentials — you want secrets stored in n8n's encrypted credential store, not hardcoded in expressions
  • UX — teammates shouldn't need to know the API schema; the node UI documents it for them
  • Type safetyTypeScript catches param name typos before runtime

n8n's custom node system is a first-class feature, not a hack. It uses the same INodeType interface the built-in nodes use.


Prerequisites

  • Node.js 20+ and npm 9+
  • n8n installed locally (npm install -g n8n)
  • TypeScript familiarity (interfaces, generics)
  • A REST API to wrap — this tutorial uses a fictional https://api.taskify.io for clarity; swap in your own

Solution

Step 1: Scaffold the Node Package

n8n provides an official CLI to generate a correctly structured package. Use it — the folder layout and package.json fields matter.

# Install the n8n node creator CLI globally
npm install -g @n8n/n8n-nodes-starter

# Scaffold a new node package
npx @n8n/create-n8n-nodes my-taskify-node
cd my-taskify-node
npm install

Expected output:

✔ Created project at ./my-taskify-node
✔ Installed dependencies

Inspect the generated structure:

my-taskify-node/
├── credentials/
│   └── TaskifyApi.credentials.ts   ← API key storage
├── nodes/
│   └── Taskify/
│       ├── Taskify.node.ts         ← Main node logic
│       └── taskify.svg             ← Node icon
├── package.json
└── tsconfig.json

If it fails:

  • command not found: npx → Upgrade Node.js to 20+; npx ships with npm 7+
  • EACCES permission error → Run npm install -g with sudo or fix npm global prefix

Step 2: Define the Credential Type

Credentials are separate from node logic. This keeps secrets out of workflow JSON exports.

Open credentials/TaskifyApi.credentials.ts and replace the contents:

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

export class TaskifyApi implements ICredentialType {
  name = 'taskifyApi';
  displayName = 'Taskify API';

  // Links to your API's auth docs — shown in the credential modal
  documentationUrl = 'https://docs.taskify.io/auth';

  properties: INodeProperties[] = [
    {
      displayName: 'API Key',
      name: 'apiKey',
      type: 'string',
      // typeOptions.password = masked in UI, never exported in plain text
      typeOptions: { password: true },
      default: '',
    },
    {
      displayName: 'Base URL',
      name: 'baseUrl',
      type: 'string',
      default: 'https://api.taskify.io',
      // Users can override for self-hosted instances
    },
  ];
}

Step 3: Build the Node Class

Open nodes/Taskify/Taskify.node.ts. This is where the node's UI and execution logic live.

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

export class Taskify implements INodeType {
  description: INodeTypeDescription = {
    // Internal name — never change this after publishing; breaks saved workflows
    name: 'taskify',
    displayName: 'Taskify',
    icon: 'file:taskify.svg',
    group: ['transform'],
    version: 1,
    description: 'Create, read, and update tasks in Taskify',
    defaults: { name: 'Taskify' },
    inputs: ['main'],
    outputs: ['main'],

    // Tells n8n which credential type this node uses
    credentials: [
      {
        name: 'taskifyApi',
        required: true,
      },
    ],

    properties: [
      // Resource selector — top-level grouping
      {
        displayName: 'Resource',
        name: 'resource',
        type: 'options',
        noDataExpression: true,
        options: [
          { name: 'Task', value: 'task' },
          { name: 'Project', value: 'project' },
        ],
        default: 'task',
      },

      // Operation selector — shown when Resource = Task
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        displayOptions: {
          show: { resource: ['task'] },
        },
        options: [
          { name: 'Create', value: 'create', action: 'Create a task' },
          { name: 'Get', value: 'get', action: 'Get a task by ID' },
          { name: 'Get All', value: 'getAll', action: 'List all tasks' },
        ],
        default: 'create',
      },

      // Field: Task title — only shown for Create
      {
        displayName: 'Title',
        name: 'title',
        type: 'string',
        displayOptions: {
          show: { resource: ['task'], operation: ['create'] },
        },
        default: '',
        required: true,
        description: 'Title of the new task',
      },

      // Field: Task ID — shown for Get
      {
        displayName: 'Task ID',
        name: 'taskId',
        type: 'string',
        displayOptions: {
          show: { resource: ['task'], operation: ['get'] },
        },
        default: '',
        required: true,
      },
    ],
  };

  async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
    const items = this.getInputData();
    const returnData: INodeExecutionData[] = [];

    // Pull credentials — n8n decrypts these at runtime
    const credentials = await this.getCredentials('taskifyApi');
    const baseUrl = credentials.baseUrl as string;
    const apiKey = credentials.apiKey as string;

    const headers = {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    };

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

      try {
        let responseData: unknown;

        if (resource === 'task') {
          if (operation === 'create') {
            const title = this.getNodeParameter('title', i) as string;

            const response = await this.helpers.httpRequest({
              method: 'POST',
              url: `${baseUrl}/v1/tasks`,
              headers,
              body: { title },
              json: true,
            });

            responseData = response;
          } else if (operation === 'get') {
            const taskId = this.getNodeParameter('taskId', i) as string;

            const response = await this.helpers.httpRequest({
              method: 'GET',
              url: `${baseUrl}/v1/tasks/${taskId}`,
              headers,
              json: true,
            });

            responseData = response;
          } else if (operation === 'getAll') {
            const response = await this.helpers.httpRequest({
              method: 'GET',
              url: `${baseUrl}/v1/tasks`,
              headers,
              json: true,
            });

            // getAll returns an array — wrap each item individually
            const tasks = response.data as object[];
            for (const task of tasks) {
              returnData.push({ json: task });
            }
            continue; // skip the single-item push below
          }
        }

        returnData.push({ json: responseData as object });
      } catch (error) {
        // continueOnFail lets the workflow keep running despite errors
        if (this.continueOnFail()) {
          returnData.push({ json: { error: (error as Error).message }, pairedItem: i });
          continue;
        }
        throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
      }
    }

    return [returnData];
  }
}

Step 4: Register the Node and Credential in package.json

n8n discovers your node by reading the n8n key in package.json. Make sure both files are listed:

{
  "name": "n8n-nodes-taskify",
  "version": "0.1.0",
  "n8n": {
    "n8nNodesApiVersion": 1,
    "credentials": [
      "dist/credentials/TaskifyApi.credentials.js"
    ],
    "nodes": [
      "dist/nodes/Taskify/Taskify.node.js"
    ]
  },
  "main": "index.js",
  "scripts": {
    "build": "tsc && gulp build:icons",
    "dev": "tsc --watch"
  }
}

If you skip this step, n8n silently ignores your package. No error, no node — just absence.


Don't publish to npm to test. Use npm link to load the package directly from disk.

# Compile TypeScript to dist/
npm run build

# Register this package globally so n8n can find it
npm link

# Tell n8n to load it
cd ~/.n8n
mkdir -p custom
cd custom
npm link n8n-nodes-taskify

Now start n8n:

n8n start

Expected output:

Initializing n8n process
...
n8n ready on 0.0.0.0, port 5678

Open http://localhost:5678, create a new workflow, search for "Taskify" in the node panel — it should appear.

If it fails:

  • Node doesn't appear → Check ~/.n8n/custom/node_modules/n8n-nodes-taskify/dist/ exists; re-run npm run build if dist/ is empty
  • Cannot find module error in n8n logs → The main field in package.json points to a missing file; set it to "dist/index.js" or remove it

Verification

Open n8n at http://localhost:5678 and run this smoke test:

  1. Add a Manual Trigger node
  2. Add your Taskify node, connect it
  3. Set Resource → Task, Operation → Create, Title → "Test task from n8n"
  4. Add a Taskify credential with a valid API key
  5. Click Execute Workflow

You should see: A JSON output item with the created task's id, title, and createdAt fields returned from the API.

To confirm TypeScript compiled cleanly with no errors:

cd my-taskify-node
npx tsc --noEmit

You should see: No output (silence = success in tsc).


What You Learned

  • n8n nodes are plain TypeScript classes implementing INodeType — no magic framework
  • displayOptions.show drives conditional field visibility; use it to keep the UI clean
  • this.helpers.httpRequest is preferred over raw fetch — it respects n8n's proxy settings and retry logic
  • npm link is the right local testing pattern; don't publish to npm on every change
  • Credential names are a contract — the string 'taskifyApi' in the node must exactly match the name field in the credential class

Limitation: The declarative property system covers 95% of use cases, but complex multi-step auth flows (OAuth2 with PKCE, dynamic field loading from an API) require the ICredentialTestRequest interface and loadOptionsMethod — topics for a follow-up article.

Tested on n8n 1.82.0, Node.js 20.11, TypeScript 5.4, macOS 15 and Ubuntu 24.04