n8n custom node TypeScript boilerplate

    The smallest complete n8n node — description plus execute — with the declarative-vs-programmatic call, credentials, local linking, the linter n8n enforces, and the monorepo pattern once you have more than one.

    Last updated 2026-09-03~8 min read
    n8nTypeScriptNode development

    Anatomy of an n8n node

    An n8n node is two things: a description that tells the editor what the node looks like — its name, its credentials, its properties — and an execute path that runs when the workflow fires. Everything else is scaffolding around those two. Get the description right and the node appears correctly in the canvas; get execute right and it does work. The boilerplate below is the smallest thing that has both.

    Nodes live in a package that n8n loads at startup. The package's package.json declares an n8n block listing the node and credential files, n8n reads that block, and the node shows up. There is no registry call and no plugin API — n8n literally imports your compiled JavaScript. That's why local development is just "build, link, restart."

    Declarative vs programmatic — pick before you write

    n8n supports two node styles and the choice shapes everything after it. A declarative node describes its API calls as data — you define the base URL, the routing, and the fields, and n8n makes the HTTP request for you. A programmatic node has an execute() method where you write the logic yourself. Declarative is the right default for a straightforward REST wrapper: less code, fewer bugs, and n8n handles pagination and errors consistently. Reach for programmatic only when you need real logic — branching, multi-step calls, data transforms the declarative router can't express. Most first nodes should be declarative and most people reach for programmatic too early.

    The boilerplate, walked through

    Start from n8n's official starter, n8n-nodes-starter — it's the canonical scaffold and staying close to it keeps you compatible with the linter n8n runs before it will verify a community node. Clone it, then the node file is where you work:

    // nodes/MyService/MyService.node.ts  (declarative)
    import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
    
    export class MyService implements INodeType {
      description: INodeTypeDescription = {
        displayName: 'My Service',
        name: 'myService',
        icon: 'file:myservice.svg',
        group: ['transform'],
        version: 1,
        description: 'Talk to My Service API',
        defaults: { name: 'My Service' },
        inputs: ['main'],
        outputs: ['main'],
        credentials: [{ name: 'myServiceApi', required: true }],
        requestDefaults: {
          baseURL: 'https://api.myservice.com/v1',
          headers: { 'Content-Type': 'application/json' },
        },
        properties: [
          {
            displayName: 'Resource',
            name: 'resource',
            type: 'options',
            noDataExpression: true,
            options: [{ name: 'Contact', value: 'contact' }],
            default: 'contact',
          },
          {
            displayName: 'Operation',
            name: 'operation',
            type: 'options',
            noDataExpression: true,
            displayOptions: { show: { resource: ['contact'] } },
            options: [{
              name: 'Get', value: 'get', action: 'Get a contact',
              routing: { request: { method: 'GET', url: '=/contacts/{{$parameter.contactId}}' } },
            }],
            default: 'get',
          },
          {
            displayName: 'Contact ID',
            name: 'contactId',
            type: 'string',
            default: '',
            displayOptions: { show: { resource: ['contact'], operation: ['get'] } },
          },
        ],
      };
    }

    That's a complete, working node. The routing block on the operation is what makes it declarative — n8n builds the GET request from requestDefaults.baseURL plus the operation's URL, injects the credential, and returns the response as items. You wrote zero HTTP code.

    Credentials are a separate file

    Credentials get their own class so n8n can store them encrypted and reuse them across nodes. The important part is authenticate, which tells n8n how to attach the secret to every request — for most APIs that's a header:

    // credentials/MyServiceApi.credentials.ts
    import type { ICredentialType, INodeProperties } from 'n8n-workflow';
    
    export class MyServiceApi implements ICredentialType {
      name = 'myServiceApi';
      displayName = 'My Service 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}}' } },
      };
    }

    If you're wrapping GoHighLevel, this is also where the mandatory Version header goes — a GHL token without it returns 401 regardless of scopes, which is the same trap the 401 fix covers from the workflow side.

    Local dev and linking

    The loop is: build to JavaScript, link the package into your n8n install, restart. npm link makes your package visible to a globally-installed n8n; point N8N_CUSTOM_EXTENSIONS at your build folder if you run n8n another way. Then it's edit → npm run build → restart n8n → check the canvas. There's no hot reload for nodes — the restart is unavoidable because n8n imports the compiled module once at boot.

    npm run build          # tsc → dist/
    npm link               # register this package globally
    cd ~/.n8n/nodes && npm link n8n-nodes-myservice
    n8n start              # restart; node now appears in the editor

    Testing before you ship

    Two layers. Run n8n's own linter — npx @n8n/scan-community-package and the eslint-plugin-n8n-nodes-base rules — because n8n enforces them before a node can be marked verified, and fixing them after the fact is tedious. Then unit-test any real logic in a programmatic node's execute() the way you'd test any TypeScript: extract the transform into a plain function and assert on it. Declarative nodes need less of this because the routing is data, but you should still click through every operation in a live workflow before publishing.

    Publishing

    A community node is just an npm package whose name starts with n8n-nodes- and which carries the n8n-community-node-package keyword. You publish it to npm like any package, and n8n's community-nodes installer pulls it by name. The verification step — getting it into n8n's in-app verified list — is a separate submission on top of the npm publish. I keep the mechanics of a clean publish and the verification checklist deliberately out of this page's claims about any specific package's current published state; the process is what's described here, not an assertion about what's live right now.

    Scaling to many nodes: the monorepo pattern

    One node is one package. The moment you have three or four, copy-pasting the build config, the linter setup, and the release scripts across separate repos becomes the actual work. The fix is a monorepo: a single repository with a shared build and lint pipeline and one package per node under it, each still publishable independently. You get one place to bump the n8n peer-dependency, one CI config, and one linter pass across every node, while npm still sees them as separate installable packages. That's the structure the qureshi-basit/n8n-nodes repo uses — the individual nodes surface in the site's node directory. When a shared change lands — a new n8n version, a lint-rule bump — you make it once at the root instead of in N repos, which is the whole reason the pattern pays off past the third node.

    Next: build one of these nodes with Claude Code →

    Need this workflow customized or debugged? Book a 30-min scope call →

    Stuck configuring this workflow?

    I build and fix n8n workflows for agencies and SaaS teams. 30-minute scope call — bring the problem, leave with an estimate.

    Related pages