Practical Guide for Building Custom Nodes for N8N
Let's dive right into what I consider the heart of building any great n8n node. Everything starts by the initial phase—where you're planning and designing everything before you touch the code—is where the magic happens. It's here that you'll set up your node to be either a seamless tool that people love using or something that leaves them scratching their heads. We'll start by covering the key technical choices you need to make at the foundation, and then we'll explore how to create a user interface that's intuitive, robust, and perfectly aligned with n8n's overall ecosystem. By the time we're done with this section, you'll have a solid, detailed blueprint ready to guide your actual development work.
Chapter 1: Foundational Technical Decisions
Before we even think about the visual side of your node—how it looks or feels—we've got to nail down its core functionality. These two big decisions, about the node type and how you'll build it, form the structural foundation for everything else. They're like choosing the right materials and framework before constructing a building; get them right, and the rest falls into place more easily.
1.1. Choosing Your Node Type: Action vs. Trigger
In the world of n8n, every node falls into one of two main categories: it either kicks off a workflow or it handles a specific job inside one. This choice is your starting point, and it's crucial because it shapes how the node interacts with the rest of the system.
- Action Nodes: These are the reliable performers in n8n workflows. An Action node takes data from whatever node comes before it, does its thing—like creating a user account, sending out an email, or updating a row in a spreadsheet—and then hands off the results to the next node down the line. Most of the nodes you'll end up building will fit into this category, as they're all about getting work done mid-flow.
- When to build an Action node: Go for this if you're connecting to a service to carry out tasks right in the middle of a workflow.
- Examples:
- A node that adds a new contact to a CRM system.
- A node that sends a message to a Slack channel.
- A node that retrieves a file from cloud storage.
- A node that transforms data from one format to another.
- Trigger Nodes: These are what get the whole workflow started. They don't pull in data from other nodes; instead, they wait for something to happen externally—like an event or a condition—and when it does, they produce the starting data that launches the workflow. Keep in mind that while a workflow might have several triggers set up, only one actually fires for each individual run.
- When to build a Trigger node: Choose this if your goal is to begin a workflow in response to an event from an outside service.
- Sub-types of Trigger Nodes: Triggers aren't all the same; they're divided based on how they spot those events. You'll need to pick the right one depending on what the API you're working with can do.
Trigger Type | Description & How it Works | When to Use It | Example Nodes |
---|---|---|---|
Webhook | These nodes give you a unique URL right in n8n. You set up this URL with the external service, and when something happens—like a new order coming in—the service pings that URL with an HTTP request in real time, which immediately starts the workflow. It's efficient and often the go-to method. | This is ideal when the service's API lets you use webhooks (which might go by names like "callbacks" or "push notifications"). It's perfect for scenarios where you need instant, real-time responses. | Zendesk Trigger: It activates right away when a new ticket pops up in Zendesk. Telegram Trigger: It activates right away when your bot gets a new message. |
Polling | Unlike webhooks, these don't get instant notifications. n8n runs them on a set schedule, say every 5 minutes, and the node reaches out to the service's API to check: "Anything new since last time?" If there is, it uses that data to start the workflow. | Fall back on this when the service doesn't support webhooks and you have to actively pull for updates periodically. | Gmail Trigger: It checks your Gmail inbox at intervals for new emails that fit specific filters. RSS Feed Read Trigger: It checks an RSS feed at intervals for fresh articles. |
Others | This catches the specialized triggers that don't match webhook or polling setups. They're for handling real-time stuff beyond basic HTTP, like linking to message queues or sticking to a strict schedule. | Opt for this with things like message brokers (such as RabbitMQ, AMQP, or MQTT), purely time-driven events, or direct links to services like IMAP for email. | Schedule Trigger: It goes off at a precise time or on a repeat schedule, like every Monday morning at 9 AM. RabbitMQ Trigger: It stays listening and pulls in messages from a RabbitMQ queue. |
1.2. Choosing Your Building Approach: Declarative vs. Programmatic
With your node type sorted out, the next step is figuring out how to actually code its behavior. n8n gives you two main ways to approach this, each with its own advantages and best-fit scenarios. This decision will shape your code's overall architecture quite a bit.
The Declarative Style: The Recommended Default
The declarative approach is the contemporary, go-to method for most n8n nodes, particularly those dealing with straightforward REST APIs. Rather than writing step-by-step code to put together and fire off HTTP requests, you simply describe the request's setup in a JSON-like format inside your node's property definitions.
- Key Characteristics:
- It relies on a JSON-style syntax in the
routing
property. - The n8n engine takes care of the heavy lifting, like assembling and sending the HTTP request according to what you've declared.
- You usually won't need an
execute()
method to handle the operations.
- It relies on a JSON-style syntax in the
- Pros:
- Simpler & Faster: It cuts down on the amount of code you have to write, speeding up development and making things easier to grasp.
- Less Error-Prone: Since n8n's core manages the request details, there's less room for mistakes in your custom code.
- More Future-Proof: When n8n updates its engine with improvements, your declarative nodes get those benefits automatically, no updates needed on your end.
- When to use the Declarative Style:
- This is your best bet for all Action nodes that work with a standard REST API.
- It's the standard pick for nearly every new node you create.
The Programmatic Style: For Maximum Flexibility and Control
The programmatic approach is the classic, more detailed way to construct nodes. Here, you write out the entire execution logic in a TypeScript function named the execute()
method. In that function, you handle everything yourself: pulling in user inputs, building the request, calling the API, and shaping the output data.
- Key Characteristics:
- Everything revolves around an
async execute()
method where the logic lives. - You get total, granular control over each part of the process.
- It's up to you to manage data handling, request construction, and output formatting manually.
- Everything revolves around an
- Pros:
- Ultimate Flexibility: Inside the
execute
method, you can implement whatever you need, from intricate data changes to conditional branches and loops. - Supports All APIs: This is essential for non-REST setups, like GraphQL APIs.
- Allows External Dependencies: If your node requires an outside npm package, such as a service's official SDK, programmatic is the way to go.
- Ultimate Flexibility: Inside the
- When you MUST use the Programmatic Style:
- Trigger Nodes: Every Trigger node—whether webhook, polling, or other—needs this style because of their unique event-handling logic.
- Non-REST APIs: For integrating with GraphQL or any unusual API protocols.
- Complex Data Transformation: When the node has to heavily rework incoming data before sending it to the API.
- Nodes using external npm packages (SDKs).
- Nodes requiring full versioning control.
Side-by-Side Comparison: "FriendGrid" Example
To really see how these two styles differ, let's look at a couple of streamlined examples for a made-up "FriendGrid" node (think of it as a SendGrid stand-in) that adds a contact. These code snippets spotlight the main variations in how you structure and write things.
In Programmatic Style:
// The programmatic style requires you to write an `execute` method.
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
IRequestOptions,
} from 'n8n-workflow';
export class FriendGrid implements INodeType {
description: INodeTypeDescription = {
// ... node properties like displayName, name, etc.
properties: [
// ... Resource and Operation properties defined here
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Create',
value: 'create',
description: 'Create a contact',
},
],
default: 'create',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
required: true,
},
],
};
// VVVV THIS IS THE CORE OF THE PROGRAMMATIC STYLE VVVV
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let responseData;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
const credentials = await this.getCredentials('friendGridApi');
if (resource === 'contact') {
if (operation === 'create') {
// 1. Manually get the user-provided parameters
const email = this.getNodeParameter('email', 0) as string;
// 2. Manually construct the data body for the request
const data = {
email,
};
// 3. Manually build the entire HTTP request options object
const options: IRequestOptions = {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${credentials.apiKey}`,
},
method: 'PUT',
body: {
contacts: [data],
},
url: `https://api.sendgrid.com/v3/marketing/contacts`,
json: true,
};
// 4. Manually execute the HTTP request helper
responseData = await this.helpers.httpRequest(options);
}
}
// 5. Manually format the response for n8n
return [this.helpers.returnJsonArray(responseData)];
}
}
In Declarative Style:
// The declarative style has NO `execute` method.
// All the logic is declared within the properties.
import { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class FriendGrid implements INodeType {
description: INodeTypeDescription = {
// ... node properties like displayName, name, etc.
requestDefaults: {
baseURL: 'https://api.sendgrid.com/v3/marketing'
},
properties: [
// ... Resource property defined here
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Create',
value: 'create',
description: 'Create a contact',
// VVVV THIS IS THE CORE OF THE DECLARATIVE STYLE VVVV
routing: {
// 1. Declare the request details
request: {
method: 'PUT',
url: '/contacts',
// 2. Declare how to send the data
send: {
type: 'body',
// 3. Declare the structure of the body,
// mapping node parameters directly
properties: {
contacts: [
{
email: '={{$parameter["email"]}}'
}
]
}
}
}
},
// 4. (Optional) Declare how to process the response
output: {
postReceive: [
{
type: 'set',
properties: {
value: '={{ { "success": $response } }}'
}
}
]
}
},
],
default: 'create',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
required: true,
},
],
};
// No `execute` method is needed!
}
Decision Summary: Start with the Declarative Style as your default—it's simpler and more resilient for most cases. Switch to the Programmatic Style only if you run into something declarative can't cover, like creating a trigger or hooking into a GraphQL API.
Chapter 2: Designing a World-Class User Interface (UI/UX)
The real value of your node shines through when users can easily figure it out and work with it. This chapter walks you through crafting an interface that's straightforward, user-friendly, and in sync with how n8n feels overall. You're not just slapping fields onto every API parameter; you're building a thoughtful experience that guides people effectively.
2.1. Core Design Philosophy: From API to GUI
One pitfall I see a lot with new developers is taking the API docs and turning them straight into the node's interface. That usually ends up with a messy, jargon-heavy setup that's hard to navigate. Think of yourself as an interpreter, bridging the technical API world to something more approachable for users.
Before you start laying out fields, dig deep into the API documentation and reflect on these key questions:
- What can I leave out? Do users truly need every optional parameter exposed? Could some get sensible defaults handled quietly in the background? Keeping things tidy makes for a stronger interface.
- What can I simplify? Are there complicated setups that could be made friendlier? For instance, swap a raw Unix timestamp input for a handy date-time picker.
- Which parts of the API are confusing? Spot the thorny bits in the API. Use your node's UI to clear them up with smarter names, clear descriptions, and sensible field groupings.
- What is the user's primary goal? Consider the typical tasks people want to accomplish with this API, and shape the UI to streamline those as much as possible.
A smart move is grabbing a wireframing tool—or even just sketching on paper—to map out your field arrangements early. This lets you play around with the sequence and grouping before diving into code.
2.2. UI Text and Naming Conventions
Sticking to consistent text and names is essential for a polished feel. By following n8n's established patterns, your community-built node will blend right in like it's always been there.
UI Text Style Guide
This table lays out the capitalization rules for various UI parts.
Element | Style | Example |
---|---|---|
Drop-down value | Title Case | All Public Channels |
Hint | Sentence case | Provide a comma-separated list of IDs. |
Info box | Sentence case | This operation is permanent and cannot be undone. |
Node name | Title Case | Google Sheets |
Parameter name | Title Case | Sheet Name |
Subtitle | Title Case | Create: Contact |
Tooltip | Sentence case | Whether to return all available fields. |
Rationale: We use Title Case for things like names and options that need to pop visually. Sentence case works better for explanatory text, making it easier to read.
UI Text Terminology Guide
- Use the Service's Language: Mirror the terms from the service's own interface. For a Notion node, talk about "Databases" and "Pages," not "Tables" and "Documents." It matches what users already know.
- Prefer GUI over API Language: If the service uses one term in its app and another in the API docs, go with the app's term. You can note the API version in a hint for those who need it.
- Avoid Technical Jargon: Swap out complex words for simpler ones where you can. Say "Connect" rather than "Authenticate," or "List" instead of "Enumerate."
- Be Consistent: Once you pick a term like "Folder," stick with it throughout. Don't flip between "Folder" and "Directory."
Node Naming Conventions
Convention | Correct | Incorrect |
---|---|---|
If a node is a trigger, the display name must end with Trigger . |
Shopify Trigger |
ShopifyTrigger , Shopify trigger |
Do not include the word "Node" in the display name. It is redundant. | Asana |
Asana Node , Asana node |
2.3. Structuring Node Properties for Clarity
How you arrange and show your node's fields plays a huge role in how easy it is to use. Stick to this layered structure to create a flow that's logical and easy to anticipate.
- Credentials (Automatic): n8n handles this for you, putting the credential picker right at the top. No need to worry about its placement.
- Resource: If your node deals with various data types—like users, projects, or tasks—make the first field a dropdown called Resource. It lets users pick the big-picture category early on.
- Operation: Right after that, add a dropdown named Operation. The choices here should adjust based on the Resource selected. For "User," you might see options like "Create," "Update," "Get," and "Delete."
- Required Fields: Follow the Operation with all the must-have fields for that action to work. Arrange them sensibly:
- From the most critical to the less so.
- From wider context to specifics. Say, for adding a comment to a task, go
Project
, thenTask
, thenComment Text
.
- Optional Fields: Tuck away anything non-essential in a collapsible section that's hidden at first. Users can expand it if needed. This "Progressive Disclosure" keeps the main view uncluttered.
- Within "Optional Fields," sort them alphabetically for quick scanning.
- For tons of options, break them into themed groups like "Formatting Options" or "Filter Options."
Example of Progressive Disclosure: Picture a "Send Email" node. There's a checkbox for "Add Attachment?" that's off by default, hiding the "File Upload" field. Check it, and the field shows up. This avoids overwhelming users with irrelevant stuff.
2.4. Providing Excellent In-UI Help
A well-crafted interface still benefits from smart guidance. n8n offers five ways to deliver that help and context—pick the right one for the job.
Help Type | Visual Appearance | When to Use It | Example Text |
---|---|---|---|
Info Boxes | A prominent yellow box that appears between fields. | For essential, critical information the user must know. Use sparingly to maintain their impact. | This operation will delete all data permanently. |
Parameter Hints | A small line of text displayed directly beneath a field. | For providing a quick, contextual tip or format requirement for a specific field. | Enter a comma-separated list of tags. |
Node Hints | Informational text in the input or output panels. | To provide general help about the node's behavior, inputs, or outputs. | This node will output one item for each file found. |
Tooltips | A ? icon next to a field name; text appears on hover. |
For extra, non-essential information that might be useful. A good place for API-specific details or longer explanations. | Whether to include archived records in the results. Defaults to false. |
Placeholder Text | Grayed-out text inside an empty input field. | To show the user an example of the expected value or format. | e.g., project-12345 |
Key Takeaway: Avoid just dumping API descriptions into tooltips. Digest them, rephrase in everyday language, and focus on the user's viewpoint to truly enhance understanding.
2.5. Implementing Common UI Patterns
The n8n community has honed some standard ways to tackle frequent design challenges. Using these will make your node feel right at home for seasoned users.
- Handling IDs: The "Name or ID" Pattern
For operations needing a specific record—like a project, user, or channel—give users dual options:- By Name (User-Friendly): A dropdown filled with record names, powered by the
loadOptions
property. - By ID (for Automation): A text box for entering or expressing an ID from earlier nodes.
- By Name (User-Friendly): A dropdown filled with record names, powered by the
- Implementation: Call the field
<Record Name> Name or ID
(e.g., Workspace Name or ID). - Tooltip: Include this exact tooltip: "Choose a name from the list, or specify an ID using an expression."
- "Simplify Response" Toggle
Many APIs spit out heaps of nested data and extras that aren't always needed. Adding a toggle for simplifying is a smart UX move.- Name:
Simplify Response
- Description:
Whether to return a simplified version of the response instead of the raw data
- Name:
- "Create or Update" (Upsert) Operations
Upserts are handy for creating if something's missing or updating if it's there. Treat this as a separate operation.- Name:
Create or Update
- Description:
Create a new record, or update the current one if it already exists (upsert)
- Name:
- Dates and Timestamps
Use thedateTime
type for all date/time fields to give users a consistent picker. Your code should handle any valid ISO 8601 format it outputs. - JSON Inputs
For JSON-expecting fields, support both:- Direct entry or paste of a JSON string.
- Expressions yielding a JSON object from prior nodes.
Code-wise, parse strings or use objects as-is.
- Toggles vs. Lists
- Go for a Toggle (on/off) only if both states are crystal clear. Like
Simplify Output?
—off means no simplification, obviously. - Use a Dropdown List if the off-state is fuzzy. For
Append?
, it's vague what off means; better with options likeAppend to Existing Data
andOverwrite Existing Data
for clarity.
- Go for a Toggle (on/off) only if both states are crystal clear. Like
Part 2: The Developer's Toolkit: Mastering the n8n Starter Project
Now that we've laid out the theoretical and design foundations in Part 1, it's time to roll up our sleeves and get into the practical side of things. Think of this section as your all-in-one guide to the official n8n-nodes-starter project—it's the blueprint the n8n team hands out for anyone building community nodes. We're not just going to copy it blindly; instead, we'll treat it like a full toolkit that you can truly master.
We'll start by carefully setting up your local development environment to make sure everything runs smoothly without any frustrating hiccups. From there, we'll dive deep into breaking down the starter project itself, looking closely at every single file and folder to figure out exactly what it does and why it's there. By the time we're done with this part, you'll have a rock-solid setup ready to go, plus a real, in-depth grasp of the architecture, the build processes, and the quality checks that make a professional n8n node package tick.
Chapter 3: Environment Setup and Project Initialization
You know how in any big building project, the foundation has to be spot on, or everything else wobbles? Well, software development works the same way. In this chapter, we're going to go through the exact steps to get your computer configured for building n8n nodes. This setup is basically a one-off thing—it matches what the n8n core developers use, so it cuts out a ton of potential headaches from compatibility issues. What we're creating here is a stable, reliable workspace where you can build without distractions.
3.1. Prerequisites and Tooling
Before we even think about generating our project, we need to get the key command-line tools in place. These handle everything from managing your code to running the app and testing your node.
1. Git: Your Code's Version Control System
Git is the go-to tool for version control in the industry. It's like having a super-reliable save feature for your whole project—you can track every change, jump back to earlier versions if something goes wrong, and even work with others seamlessly. It's non-negotiable for modern dev work.
- Action: If Git isn't already on your machine, head over to the official site to download and install it: https://git-scm.com/downloads. Just follow the steps tailored to your OS, whether that's Windows, macOS, or Linux.
2. Node.js & npm: The JavaScript Runtime and Package Manager
- Node.js is what lets you run JavaScript code outside a browser. Since n8n and its nodes are built in TypeScript—which gets compiled down to JavaScript—Node.js is the core engine powering everything.
- npm (Node Package Manager) comes bundled with Node.js. It's your tool for grabbing and handling third-party libraries, or "packages," that the project relies on—like the n8n workflow engine or the linter.
For the n8n-nodes-starter project, you'll need Node.js version 20 or higher. The smartest way to handle this is with a version manager. It lets you install different Node versions side by side and switch them up depending on the project, which is huge for keeping things compatible over time.
- Action for Linux, macOS, or Windows Subsystem for Linux (WSL): Use
nvm
(Node Version Manager)- Check out the official install guide on the nvm GitHub repo: https://github.com/nvm-sh/nvm. It's usually just one command in your terminal.
- Once it's installed, close your terminal and open a fresh one.
- Action for Windows (without WSL): Use the Official Installer or a Windows-specific manager
- nvm is great, but for straight-up Windows, follow Microsoft's official guide to Install NodeJS on Windows. It covers direct installs or using something like nvs (Node Version Switcher).
- No matter the method, make sure you end up with Node.js version 20 or above.
Switch your system to use it by running:
nvm use 20
Grab Node.js version 20 with this command:
nvm install 20
3. n8n Global Installation: Your Local Test Bench
To properly test the node you're building, you need n8n running right on your machine. Installing it globally means the n8n command is available from anywhere in your terminal, so you can fire up a test server without hassle.
- Explanation of the Command:
npm
: Kicks off the Node Package Manager.install
: Tells it to download and set up a package.n8n
: That's the package we're after.-g
: Stands for "global"—it installs everything in a system-wide spot, so commands like n8n are accessible from any folder, not just one project.
Action: Fire up your terminal and run:
npm install n8n -g
With Git, Node.js (v20+), and global n8n in place, your setup is primed and ready for action.
3.2. Generating and Cloning Your Node Project
Alright, environment's good to go—now let's create your actual node project based on the official n8n-nodes-starter template. Using GitHub's template feature gives you a fresh repository in your account that's an exact copy, but without any old commit history hanging around.
- Generate Your Repository:
- Action: Open your browser and go to: https://github.com/n8n-io/n8n-nodes-starter/generate
- Process: GitHub will ask for an owner (that's your account) and a repo name. Pick something clear, like n8n-nodes-my-service. Then hit "Create repository from template."
- Clone Your New Repository:
Cloning pulls down a copy of that remote repo from GitHub to your local setup.- This creates a new folder matching your repo name and downloads all the files into it.
- Navigate into the Project Directory:
- Install Project Dependencies:
Your cloned project has a package.json file listing all the libraries and tools it needs—these are its dependencies. npm install reads that list and fetches them.- Process: This spins up a node_modules folder and fills it with essentials, like the TypeScript compiler, n8n type definitions for code suggestions, and ESLint for code checks.
Action: From the project's root, run:
npm install
Action: You're still in the parent folder in your terminal—move into the project one with:
cd <your-repo-name>
Action: On your new GitHub repo page, click the green "<> Code" button and copy the HTTPS or SSH URL. Open your terminal, cd to where you keep your dev projects, and run git clone with your URL in place of the placeholder.
git clone https://github.com/<your-organization>/<your-repo-name>.git
Your project's now fully set up and waiting for you to dive in.
3.3. Linking and Running Your Node Locally for Testing
This step is key for a tight development cycle. You've got your node code in its folder, and your global n8n test setup elsewhere. Linking creates a symlink—a fancy shortcut—that lets n8n pull your node straight from your project's source.
The beauty here is you can tweak your code, rebuild, and see changes right away in the browser when you refresh n8n—no need to publish to npm constantly.
The best place for the latest details is the official n8n docs—they're the gold standard.
- Action: Stick closely to the official guide: Run your node locally.
- Conceptual Overview of the Process: The guide's steps usually cover:
- Cd-ing to your custom node's directory (say, ~/dev/n8n-nodes-my-service).
- Building the node initially with something like npm run build. This turns your .ts files into .js in the dist folder.
- Setting an environment variable like N8N_CUSTOM_EXTENSIONS_DIR to point at your project dir.
- Launching local n8n with n8n start.
- The Iterative Development Loop: With linking done, your routine becomes:
- Edit your .ts files.
- Run npm run build to recompile.
- Restart n8n start (or set it to auto-watch changes).
- Refresh the n8n interface in your browser—your node's updated and testable.
Getting this loop down pat is what lets you develop nodes fast and without frustration.
Chapter 4: Anatomy of a Node Package
With the project up and running, let's pop open that folder and really pick it apart. Knowing what each file and directory does helps you figure out where to put your code, how to handle icons, and how to tweak the project's setup.
4.1. Core Project Structure
The n8n-nodes-starter is built with a clean, modular layout that keeps things organized and separate.
Project File Tree:
.
├── .eslintrc.prepublish.js // Special linting rules for publishing
├── .npmignore // Files to exclude when publishing to npm
├── README.md // Project documentation
├── credentials/ // Directory for all your credential types
│ ├── ExampleCredentialsApi.credentials.ts
│ └── HttpBinApi.credentials.ts
├── gulpfile.js // Build task definitions (e.g., for icons)
├── index.js // Main entry point (often empty or minimal)
├── nodes/ // Directory for all your node logic
│ ├── ExampleNode/
│ │ └── ExampleNode.node.ts
│ └── HttpBin/
│ ├── HttpBin.node.ts
│ └── HttpVerbDescription.ts
├── package.json // The manifest file for the project
└── tsconfig.json // TypeScript compiler configuration
Directory and File Purposes:
nodes/
: This is your main workspace. It's home to the core logic of your nodes.- Best Practice: Give each node its own sub-folder, like nodes/MyAwesomeNode/.
- The primary file for a node follows the pattern NodeName.node.ts.
- For more complex nodes, you can split things across files in that sub-folder, just like HttpBin.node.ts and HttpVerbDescription.ts do.
credentials/
: Where you define custom auth methods.- Each one gets its own file, named CredentialName.credentials.ts.
dist/
: The output folder for built files.- You don't touch this directly.
- Running npm run build has the TypeScript compiler (tsc) take .ts files from nodes/ and credentials/, turn them into .js, and drop them here.
- It also copies over static stuff like icons (.png, .svg).
- n8n loads and runs from this dist folder.
package.json
: The project's central manifest in JSON. It covers:- Metadata: Things like name, version, description, author, license.
- Entry Point: main (links to the key file in dist).
- Dependencies: dependencies (runtime libs) and devDependencies (dev-only, like TypeScript, ESLint).
- Scripts: scripts (shortcuts for commands like build, lint, test).
tsconfig.json
: Sets up the TypeScript compiler—tells it the JS target version, where sources are, and more.
Architectural Diagram and Principle:
The setup follows Dependency Inversion, a smart design principle. Your node doesn't know the n8n engine details, and vice versa—they talk via a shared interface.
graph TD
subgraph n8n Host Application
A[n8n Engine]
end
subgraph Your Custom Node Package
B(HttpBin Node) -- Implements --> C{INodeType}
D(ExampleNode Node) -- Implements --> C{INodeType}
E(HttpBinApi Credential) -- Implements --> F{ICredentialType}
G(ExampleCredentialsApi Credential) -- Implements --> F{ICredentialType}
end
subgraph n8n Framework
C{INodeType Interface}
F{ICredentialType Interface}
end
A -- Loads & Executes --> B
A -- Loads & Executes --> D
A -- Loads & Manages --> E
A -- Loads & Manages --> G
B -- Optionally Uses --> E
- Your node class (like HttpBin) implements the INodeType interface from n8n. It's a contract ensuring your node has what n8n needs, like a description property.
- n8n loads your package, spots classes matching INodeType, and handles display and execution without digging into internals. This keeps everything modular and easy to extend.
4.2. The Build System: gulpfile.js
and package.json
Scripts
The build system automates turning your code into a runnable package. Here, it's a mix of npm scripts and Gulp.
- Role of
Gulp
: It's a task runner, mainly for non-code stuff like icons that tsc can't handle.
Take a close look at the gulpfile.js from the starter:
const path = require('path');
const { task, src, dest } = require('gulp');
task('build:icons', copyIcons);
function copyIcons() {
const nodeSource = path.resolve('nodes', '**', '*.{png,svg}');
const nodeDestination = path.resolve('dist', 'nodes');
src(nodeSource).pipe(dest(nodeDestination));
const credSource = path.resolve('credentials', '**', '*.{png,svg}');
const credDestination = path.resolve('dist', 'credentials');
return src(credSource).pipe(dest(credDestination));
}
- Line-by-Line Breakdown:
task('build:icons', copyIcons);
: Sets up a task called build:icons that runs copyIcons.const nodeSource = path.resolve('nodes', '**', '*.{png,svg}');
: Defines where to find node icons. The pattern: nodes as base, ** for recursive search, *.{png,svg} for files ending in .png or .svg. So, grab every .png and .svg deep in nodes/.const nodeDestination = path.resolve('dist', 'nodes');
: Where to put them.src(nodeSource).pipe(dest(nodeDestination));
: Core action—source files piped to dest, copying icons.- The last bits do the same for credentials/ icons.
- Running npm run build:
- gulp build:icons copies icons to dist.
- && chains to the next if successful.
- tsc compiles .ts to .js in dist.
Role of npm scripts
: You use these from package.json to run builds, not Gulp directly. A typical build script:
"scripts": {
"build": "gulp build:icons && tsc"
}
4.3. Code Quality and Linting: .eslintrc.js
A linter like ESLint checks code statically—spotting issues without running it. It keeps style consistent and catches errors early.
- Purpose of ESLint:
- Consistency: Uniform rules for formatting, like indents, quotes, spaces.
- Error Prevention: Flags things like unused vars or dead code.
- Best Practices: Pushes n8n-specific node dev standards.
- How to Use It: Two handy scripts:
npm run lint
: Scans code, reports problems in terminal. Run it often.npm run lintfix
: Fixes what it can auto, like formatting.- What it does: Adds the rule community-package-json-name-still-default.
- How it works: Checks package.json's name—if it's still "n8n-nodes-starter," it errors out.
- The Impact: Pre-publish scripts run linting; an error here stops npm publish.
- Why it's Crucial: Stops you from pushing a default starter to npm. You must rename to something unique (e.g., n8n-nodes-my-service) first, keeping the ecosystem clean.
The Pre-publish Safety Net: .eslintrc.prepublish.js
There's a special ESLint config just for pre-publish checks.Inside .eslintrc.prepublish.js:
module.exports = {
extends: "./.eslintrc.js", // Inherits all the standard rules
overrides: [
{
files: ['package.json'], // This rule ONLY applies to the package.json file
plugins: ['eslint-plugin-n8n-nodes-base'],
rules: {
// This is the custom rule
'n8n-nodes-base/community-package-json-name-still-default': 'error',
},
},
],
};
Part 3: Practical Node Development by Example
With your development environment all set up and a solid grasp on the starter project's architecture, we're now diving straight into building nodes and credentials hands-on. This is where things shift from concepts to actual code—you'll see theory turn into something tangible. We'll use the examples from the n8n-nodes-starter
project as our reliable reference point, treating them like the gold standard.
I've organized this section as a series of deep dives into the code. We'll kick off with the simplest node to nail down the basics. From there, we'll ramp up to a more sophisticated declarative API node, where you'll learn to handle dynamic interfaces and smart data routing. And to wrap it up, we'll tackle authentication by creating two different credential types. In each chapter, I'll walk you through the code line by line, not just explaining what it does, but also why it's structured that way—because understanding the reasoning behind the choices is what makes you a stronger developer.
Chapter 5: Building Your First Node: Simple Data Transformation
Let's start our practical journey with the most straightforward kind of node: one that takes data, transforms it programmatically based on what the user inputs, and sends it forward. We'll do a thorough, line-by-line breakdown of the ExampleNode.node.ts
file. This node grabs incoming data, tacks on a new bit of info from the user's input, and passes everything along. It might seem basic at first glance, but here's the thing—its code packs in all the core elements of the programmatic approach to building nodes. It's like the perfect starting point to build your confidence.
5.1. Dissecting ExampleNode.node.ts
Go ahead and open up the file at nodes/ExampleNode/ExampleNode.node.ts
. We'll break it down from the top, piece by piece, so you can see how it all fits together.
Imports and Class Definition
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
export class ExampleNode implements INodeType {
// ... code follows
The imports here are pulling in the essential types and classes from the n8n-workflow
library. You can think of these as the foundational tools and templates n8n provides to construct your node properly.
IExecuteFunctions
: This interface outlines the helper functions you'll have access to inside theexecute
method, things likethis.getNodeParameter
that make your life easier.INodeExecutionData
: This represents a single "item" in n8n's data flow. It's got ajson
property where the actual data hangs out.INodeType
: This is the must-have interface your node class implements. It's basically the agreement that ensures your node fits seamlessly into n8n's system.INodeTypeDescription
: This defines the object's structure that describes your node's UI and behavior in detail.NodeConnectionType
: An enum that specifies the kinds of input/output connections, likeMain
for the usual data pathway.NodeOperationError
: A custom error class for throwing errors that n8n can catch and display nicely in the interface.
Now, for the class definition: export class ExampleNode implements INodeType
.
export class ExampleNode
: This creates a TypeScript class calledExampleNode
and exports it so n8n can import and use it.implements INodeType
: This is key—it's TypeScript's way of saying this class will follow theINodeType
blueprint exactly. That means it'll have adescription
property and, since it's programmatic, anexecute
method. This setup lets n8n interact with your node predictably, no matter who built it.
The Node Description Object
This is the hefty static property that acts as the blueprint for how n8n displays and runs your node. It covers the name, icon, inputs, outputs, and every parameter the user can tweak.
description: INodeTypeDescription = {
displayName: 'Example Node',
name: 'exampleNode',
group: ['transform'],
version: 1,
description: 'Basic Example Node',
defaults: {
name: 'Example Node',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
usableAsTool: true,
properties: [
// ... property definitions
],
};
displayName: 'Example Node'
: This is the friendly name users see in the nodes panel and on the workflow canvas. It's in Title Case for consistency.name: 'exampleNode'
: The behind-the-scenes identifier, unique across all nodes. Stick to camelCase here.group: ['transform']
: This array slots your node into categories in the nodes panel, liketransform
for data manipulation, or others such asinput
,output
, ormiscellaneous
.version: 1
: Starts at 1, but if you make changes that could break existing workflows, bump this up so n8n can handle older versions gracefully.description: 'Basic Example Node'
: A bit more detail that shows up in the nodes panel to give users context.defaults: { name: 'Example Node' }
: Sets initial values when the node hits the canvas, like labeling it with the display name.inputs: [NodeConnectionType.Main]
: Defines left-side connections. This creates one standard input for data from upstream nodes.outputs: [NodeConnectionType.Main]
: Right-side connections. One standard output to forward data.usableAsTool: true
: Flags this node as compatible with n8n's AI features, like the Agent Node, because it does a clear, discrete task.properties: [ // ... ]
: We'll dive into this next—it's where all the user settings live.
The Properties Array
Here's where you spell out every input field, dropdown, or switch in the node's settings. For this simple node, there's just one property.
properties: [
{
displayName: 'My String',
name: 'myString',
type: 'string',
default: '',
placeholder: 'Placeholder value',
description: 'The description text',
},
],
displayName: 'My String'
: The label right next to the field in the UI.name: 'myString'
: The key you'll use in code to grab this value, matching the display but in code-friendly form.type: 'string'
: Dictates the UI element—a text box here. Other types could be'number'
,'boolean'
for toggles,'options'
for dropdowns, or'fixedCollection'
for key-value setups.default: ''
: What fills the field initially—an empty string in this case.placeholder: 'Placeholder value'
: The hint text inside the box when it's blank, helping users know what to put in.description: 'The description text'
: Extra guidance below the field for more explanation.
5.2. The Programmatic execute
Method in Detail
This is where the action happens in a programmatic node—the logic that fires when the workflow runs.
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
let item: INodeExecutionData;
let myString: string;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
myString = this.getNodeParameter('myString', itemIndex, '') as string;
item = items[itemIndex];
item.json.myString = myString;
} catch (error) {
// Error handling code...
}
}
return [items];
}
- Method Signature:
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>
async
: Lets you handle async tasks like API calls and useawait
. Everyexecute
needs this.this: IExecuteFunctions
: Types thethis
context for safe access to helpers likegetNodeParameter
.Promise<INodeExecutionData[][]>
: Returns a promise wrapping a nested array. Outer array for output branches (one here), inner for items per branch.
- Getting Input Data:
const items = this.getInputData();
- A standard opener: pulls all incoming items from the first input as an array of
INodeExecutionData
.
- A standard opener: pulls all incoming items from the first input as an array of
- The Main Loop:
for (let itemIndex = 0; itemIndex < items.length; itemIndex++)
- Process each item one by one—this loop is the go-to way to handle that in n8n.
- Retrieving a Parameter:
myString = this.getNodeParameter('myString', itemIndex, '') as string;
- Core function for user inputs. Arguments:
'myString'
: Matches the property name fromproperties
.itemIndex
: Ties to the current item, evaluating expressions per item (e.g.,{{ $json.name }}
).''
: Fallback if blank.as string
: TypeScript assertion for type safety.
- Core function for user inputs. Arguments:
- The Core Logic:
item.json.myString = myString;
item = items[itemIndex];
: Grab the current item.item.json
: Data lives here..myString = myString;
: Adds the new key-value. Turns{ "id": 1 }
into{ "id": 1, "myString": "some value" }
.
- Returning the Output Data:
return [items];
- Wrap the modified items in an array for the return type. For multiple outputs, it'd be like
[successItems, errorItems]
.
- Wrap the modified items in an array for the return type. For multiple outputs, it'd be like
5.3. Implementing Robust Error Handling
Nodes that users can rely on need solid error handling. This example gives you a template for doing it right in programmatic nodes.
try {
// Main logic here...
} catch (error) {
if (this.continueOnFail()) {
items.push({ json: this.getInputData(itemIndex)[0].json, error, pairedItem: itemIndex });
} else {
if (error.context) {
error.context.itemIndex = itemIndex;
throw error;
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex,
});
}
}
- The
try...catch
Block: Wrap potential failure points per item. Catches errors and handles them. if (this.continueOnFail())
: Checks the "Continue On Fail" setting.- If true: Keep going; push a special item with original json, the error, and
pairedItem
link for routing failures later. - If false: Stop workflow.
if (error.context)
: AdditemIndex
if context exists.throw new NodeOperationError(...)
: Halts with n8n-friendly error.this.getNode()
: Node context.error
: Original issue.{ itemIndex }
: Pinpoints the failed item for easy debugging.
- If true: Keep going; push a special item with original json, the error, and
Chapter 6: Building an API Node: Resources, Operations, and Routing
Now that we've got the basics down, let's level up to something more involved—a declarative node that talks to an external API. We'll pull apart the HttpBin
node to see how you create a modular, user-friendly interface with the "Resource" and "Operation" pattern. This approach makes nodes intuitive and scalable.
6.1. The Declarative "Resource" and "Operation" Pattern
This pattern is a key design principle in n8n nodes. It structures things hierarchically, much like many APIs do, making it logical for users.
Start with the main file, nodes/HttpBin/HttpBin.node.ts
. The properties
array is where the structure shines.
// in HttpBin.node.ts
import { httpVerbFields, httpVerbOperations } from './HttpVerbDescription';
// ... inside the description object
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'HTTP Verb',
value: 'httpVerb',
},
],
default: 'httpVerb',
},
...httpVerbOperations,
...httpVerbFields,
],
- Modularity: Notice the import and spreads (
...
). This keeps the main file tidy by offloading operation and field details toHttpVerbDescription.ts
. - The Resource Selector: First property sets up the top-level dropdown.
type: 'options'
: Makes it a dropdown.noDataExpression: true
: Locks it to static choices—no expressions allowed, since downstream UI depends on it.options
: Lists choices. Here, just "HTTP Verb" with valuehttpVerb
. Could expand to more like users or companies.
- Injecting Operations and Fields: Spreads pull in arrays from the other file, merging everything into one properties list.
This setup lets users pick a resource first, which then unlocks relevant operations—straight from the design principles we covered earlier.
6.2. Crafting Dynamic UIs with displayOptions
Flip to nodes/HttpBin/HttpVerbDescription.ts
for the dynamic magic. displayOptions
controls what shows when.
Showing the "Operation" Field
Check httpVerbOperations
, which defines the operation dropdown.
// in HttpVerbDescription.ts
export const httpVerbOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['httpVerb'],
},
},
// ... options like GET and DELETE
}
]
displayOptions.show
: Visibility conditions.resource: ['httpVerb']
: Shows only if resource ishttpVerb
. The dropdown appears after that selection.
Showing Operation-Specific Fields
Chain conditions for deeper dynamics. See getOperation
for GET-specific fields.
// in HttpVerbDescription.ts
const getOperation: INodeProperties[] = [
{
displayName: 'Type of Data',
name: 'typeofData',
// ...
displayOptions: {
show: {
resource: ['httpVerb'],
operation: ['get'],
},
},
// ...
},
];
- Chained Conditions:
show
with bothresource
andoperation
—an AND rule. 'Type of Data' appears only if resource ishttpVerb
AND operation isget
.
This lets you tailor fields per operation, keeping the UI focused. The deleteOperation
adds another layer, showing "Query Parameters" only if typeofData
is queryParameter
. It's all about revealing options progressively, so users aren't overwhelmed.
6.3. Sending Data with the routing
Object
In declarative nodes, routing
handles request building—no custom execute
needed.
Defining the Request's Foundation
Operation-level statics set the base. In httpVerbOperations
's options
:
// inside httpVerbOperations in HttpVerbDescription.ts
options: [
{
name: 'GET',
value: 'get',
routing: {
request: {
method: 'GET',
url: '/get',
},
},
},
// ...
]
routing.request
: For "GET", sets method toGET
and url to/get
—relative to the baseURL inHttpBin.node.ts
(https://httpbin.org
).
Mapping User Input to the Request
Property-level routing.send
maps inputs. For "Query Parameters" in getOperation
(a fixedCollection
for key-values), on the Value
field:
// inside getOperation in HttpVerbDescription.ts
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '={{$parent.key}}',
type: 'query',
},
},
}
routing.send
: How to send this field's data.type: 'query'
: Puts it in the query string. Alternatives:'body'
,'header'
,'path'
.property: '={{$parent.key}}'
: Expression grabs the key from the sibling field. Key "search" makes propertysearch
.- Value becomes the param value. Key "search", value "n8n" →
?search=n8n
.
Compare to "JSON Object" in deleteOperation
:
// inside deleteOperation in HttpVerbDescription.ts
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '={{$parent.key}}',
type: 'body',
},
},
}
- Differs only in
type: 'body'
—adds to JSON body instead. This flexibility lets you build intricate requests declaratively by routing user inputs smartly.
Chapter 7: Mastering Credentials
Handling authentication securely is crucial for API nodes. n8n's system encrypts and reuses secrets like keys or passwords. We'll break down the two examples in credentials/
to cover common patterns.
7.1. Basic Authentication: The ExampleCredentialsApi
Pattern
Basic Auth uses username/password. Let's examine credentials/ExampleCredentialsApi.credentials.ts
.
// in ExampleCredentialsApi.credentials.ts
export class ExampleCredentialsApi implements ICredentialType {
name = 'exampleCredentialsApi';
displayName = 'Example Credentials API';
properties: INodeProperties[] = [
{
displayName: 'User Name',
name: 'username',
type: 'string',
default: '',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
typeOptions: {
password: true,
},
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
auth: {
username: '={{ $credentials.username }}',
password: '={{ $credentials.password }}',
},
qs: {
n8n: 'rocks',
},
},
};
// ... test object
}
- Class Structure: Implements
ICredentialType
, like nodes. properties
Array: Defines user fields, similar to nodes.- Masking Passwords:
typeOptions: { password: true }
on password makes it masked for security.
- Masking Passwords:
authenticate
Object: Injects secrets into requests.type: 'generic'
: Works with generic HTTP nodes like "HTTP Request".properties.auth
: Triggers Basic Auth.username: '={{ $credentials.username }}'
: Maps stored username via$credentials
.properties.qs
: Adds static query paramn8n=rocks
to requests.
7.2. API Key / Bearer Token: The HttpBinApi
Pattern
For API keys or Bearer tokens in headers, see credentials/HttpBinApi.credentials.ts
.
// in HttpBinApi.credentials.ts
export class HttpBinApi implements ICredentialType {
name = 'httpbinApi';
displayName = 'HttpBin API';
properties: INodeProperties[] = [
{
displayName: 'Token',
name: 'token',
type: 'string',
default: '',
typeOptions: {
password: true, // Also good practice for tokens
}
},
// ...
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '={{"Bearer " + $credentials.token}}',
},
},
};
// ... test object
}
authenticate.properties.headers
: Usesheaders
instead ofauth
.Authorization: '={{"Bearer " + $credentials.token}}'
: Builds header with expression—concatenates "Bearer " and token forAuthorization: Bearer my-secret-api-token
.
7.3. Credential Testing and Validation
To build trust, let users test credentials. The test
property runs a check on "Test" button click.
Simple Test (ExampleCredentialsApi
)
// in ExampleCredentialsApi.credentials.ts
test: ICredentialTestRequest = {
request: {
baseURL: 'https://example.com/',
url: '',
},
};
- Tests with GET to
https://example.com/
, applyingauthenticate
. Success (e.g., 200) shows a green check.
Dynamic Test (HttpBinApi
)
// in HttpBinApi.credentials.ts
test: ICredentialTestRequest = {
request: {
baseURL: '={{$credentials?.domain}}',
url: '/bearer',
},
};
baseURL: '={{$credentials?.domain}}'
: Uses user's domain dynamically, with optional chaining for safety.url: '/bearer'
: Hits token-validation endpoint.
Overall, test
should be a safe, read-only call. n8n applies authenticate
automatically for easy validation.
Part 4: Deployment and Publishing Your Node
You've put in the hard work to plan, design, and build your node. The logic holds up, the user interface looks sharp, and you've tested it thoroughly in your local setup. Now comes the exciting part—actually sharing what you've created. In this section, we'll walk through the entire process of deploying your node, starting from setting it up for private use within your own environment to making it available publicly for the whole n8n community to enjoy.
I'll guide you through two main approaches here. The first is all about private deployment, which is ideal if you're creating something tailored for your company, like internal tools or integrations that aren't meant for the outside world—or even just for that last bit of testing in a real-world-like setup. The second focuses on publishing it publicly, so you can contribute back to the n8n ecosystem and let thousands of other automators benefit from your efforts. We'll dive into every single command, configuration step, and detail you need, so you can move forward with confidence and get your node out there successfully.
Chapter 8: Private Deployment for Internal Use
Sometimes, a node isn't designed for everyone to use—maybe it's connecting to a private database only your team has access to, or it's built around a custom API that's unique to your organization, or perhaps it's interfacing with some specialized software that stays in-house. In situations like these, you want to install it on your self-hosted n8n instance without putting it out on a public registry. Let's break down the two main ways to do this, step by step.
8.1. Installing Custom Nodes in Docker
If you're hosting n8n with Docker—which is how most people do it for self-hosting—the key is to build a custom Docker image that includes your node right from the start. That way, your node is always there, no matter if you stop the container or spin up a new one.
At the heart of this is creating a Dockerfile. You can think of it as a blueprint or a list of instructions that Docker follows to assemble your image, adding one layer at a time.
Step 1: Create Your Dockerfile
Head to the root directory of your n8n node project and make a new file called Dockerfile
—no extension needed. Paste in this content, which is based on the standard n8n Dockerfile we'll use as our foundation.
FROM node:16-alpine
ARG N8N_VERSION
RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi
# Update everything and install needed dependencies
RUN apk add --update graphicsmagick tzdata git tini su-exec
# Set a custom user to not have n8n run as root
USER root
# Install n8n and the packages it needs to build it correctly.
RUN apk --update add --virtual build-dependencies python3 build-base ca-certificates && \
npm config set python "$(which python3)" && \
npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} && \
apk del build-dependencies \
&& rm -rf /root /tmp/* /var/cache/apk/* && mkdir /root;
# Install fonts
RUN apk --no-cache add --virtual fonts msttcorefonts-installer fontconfig && \
update-ms-fonts && \
fc-cache -f && \
apk del fonts && \
find /usr/share/fonts/truetype/msttcorefonts/ -type l -exec unlink {} \; \
&& rm -rf /root /tmp/* /var/cache/apk/* && mkdir /root
ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu
WORKDIR /data
COPY docker-entrypoint.sh /docker-entrypoint.sh
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
EXPOSE 5678/tcp
Note on Node.js Version: See that FROM node:16-alpine
line? That's pulling in the base image with Node.js version 16. It's what the docs recommend, but if you're working on something newer, you might want to bump it up to node:20-alpine
or whatever matches your dev environment—just make sure it's consistent.
Step 2: Compile Your Node and Prepare Files
Before you build the image, you've got to get your node compiled into JavaScript that's ready to run.
- Still in the project's root, set up this folder structure:
custom/nodes
. It's designed to match the~/.n8n/custom/
path that n8n uses inside the container.
Copy everything from your dist
folder over to this new custom/nodes
spot. When you're done, your project layout should look roughly like this:
.
├── Dockerfile
├── custom/
│ └── nodes/
│ └── [your compiled node files and folders here]
├── dist/
├── nodes/
├── package.json
└── ...
Open your terminal in the root of your node project and run this build command:
npm run build
What this does is take all your TypeScript files (the ones ending in .ts
) and convert them to JavaScript (.js
files), dropping them into a dist
folder.
Step 3: Add the Node to the Dockerfile
Next, we need to tell the Dockerfile to include your node in the final image.
Action: Insert these lines into your Dockerfile
, just before the WORKDIR /data
part.
# Copy the custom node into the image
COPY ./custom/nodes /root/.n8n/custom/
This COPY
instruction grabs the custom/nodes
directory from your local project (what Docker calls the "build context") and places it into /root/.n8n/custom/
inside the image.
Step 4: Download the Entrypoint Script
The Dockerfile mentions a docker-entrypoint.sh
script—this is what kicks off n8n properly when the container starts.
- Action: Grab this file from the official n8n repo on GitHub.
- URL: https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/docker-entrypoint.sh
- Save the downloaded
docker-entrypoint.sh
right next to yourDockerfile
.
Step 5: Build Your Custom Docker Image
Everything's set up now, so let's have Docker follow your instructions and create the image.
- Command Breakdown:
docker build
: This kicks off the building process.--build-arg N8N_VERSION=1.22.1
: It feeds in a build argument to fill in the${N8N_VERSION}
spot in the Dockerfile. Swap out1.22.1
for whatever n8n version you're targeting.--tag=n8n-custom
: Gives your image a friendly name. Feel free to change it to something likemy-company-n8n
if that fits better..
: That dot means the current directory is where Docker looks for all the files it needs, like yourcustom
folder and the entrypoint script.
Action: In your terminal, still in the project's root (where the Dockerfile
lives), run:
docker build --build-arg N8N_VERSION=1.22.1 --tag=n8n-custom .
Docker will go through each step: pulling the base image, adding dependencies, installing n8n, and copying in your node. When it's done, type docker images
to check—your n8n-custom
should show up. Now, swap this image name into your docker-compose.yml
or docker run
setup instead of the usual n8nio/n8n
. Start it up, and your custom node will load automatically.
8.2. Installing in a Global n8n Instance
If you've got n8n installed directly on a machine—say, via npm install -g n8n
without Docker—this approach is much straightforward. The idea is that a global n8n setup checks a specific folder in your home directory for custom nodes every time it starts.
- Navigate to the n8n Custom Nodes Directory: Jump into the n8n data folder in your home directory.
- On Linux or macOS:
cd ~/.n8n/
- On Windows:
cd %USERPROFILE%/.n8n/
- On Linux or macOS:
- Install your local node package: From inside that
~/.n8n/custom
folder, usenpm install
, but point it to the full path of your node's project instead of an online package.- This sets up a symlink in the
node_modules
subdirectory, linking straight to your project—it's a smart way to handle local installs.
- This sets up a symlink in the
- Restart n8n: To make it all stick, stop your n8n instance and start it again. On restart, it'll scan
~/.n8n/custom/node_modules
, spot your linked node, and bring it into the editor.
For instance, if your project is at /Users/myuser/dev/my-n8n-node
:
npm install /Users/myuser/dev/my-n8n-node
Create the custom
directory if it doesn't exist:
mkdir custom
cd custom
Build Your Node: Make sure it's compiled to JavaScript first. In the root of your node project, run this in your terminal:
npm run build
Chapter 9: Publishing to the Community via npm
Once your node is polished and ready, the best way to share it widely is by publishing it to the npm registry—the central hub for Node.js packages. This lets any self-hosted n8n user find and install it easily.
9.1. Preparing Your package.json
for Publication
Your package.json
is like the ID card for your node—it holds all the info that npm and n8n rely on to recognize, categorize, and show off your work. Getting this right before publishing is non-negotiable, so let's go through a full checklist of what to check and update.
name
(CRITICAL):- Rule: It has to be in the format
n8n-nodes-<your-node-name>
. Think something liken8n-nodes-openweathermap
. - Reason: This pattern is what lets n8n spot your package as a community node. Skip it, and it won't show up in the n8n interface. Remember that ESLint rule from Part 2? It stops you from publishing with the starter name like
n8n-nodes-starter
.
- Rule: It has to be in the format
version
:- Rule: Stick to Semantic Versioning (SemVer)—that's MAJOR.MINOR.PATCH. Kick off with
0.1.0
or1.0.0
for your first version. - Reason: SemVer helps everyone understand your updates. Bump the:
- MAJOR for breaking changes that aren't backward-compatible.
- MINOR when adding new features that play nice with old versions.
- PATCH for fixes that don't change how things work.
- Rule: Stick to Semantic Versioning (SemVer)—that's MAJOR.MINOR.PATCH. Kick off with
description
:- Rule: Craft a short, clear sentence summing up what the node does.
- Reason: This shows up in npm searches and n8n's community nodes browser—it's your hook for getting noticed.
author
:- Rule: Put in your name, and add email or website if you want. Like:
"author": "Your Name <[email protected]>"
- Rule: Put in your name, and add email or website if you want. Like:
license
:- Rule: The starter uses "MIT," which is open and permissive—perfect for community stuff. Update the
LICENSE
file with your name and the year.
- Rule: The starter uses "MIT," which is open and permissive—perfect for community stuff. Update the
repository
:- Rule: Link it to your public Git repo, say on GitHub.
- Reason: Users can then find the source, report bugs, or contribute fixes.
keywords
(CRITICAL):- Rule: This is a string array that must have
"n8n-community-node-package"
. Toss in other relevant terms too. - Reason: That specific keyword is how n8n's "Browse Community Nodes" feature finds packages on npm. Without it, your node stays hidden in n8n.
- Rule: This is a string array that must have
Example of a well-prepared package.json
:
{
"name": "n8n-nodes-awesomeservice",
"version": "0.1.0",
"description": "An n8n node to interact with the AwesomeService API.",
"license": "MIT",
"author": "Jane Doe <[email protected]> (https://github.com/janedoe)",
"repository": {
"type": "git",
"url": "https://github.com/janedoe/n8n-nodes-awesomeservice.git"
},
"main": "dist/index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch",
"format": "prettier --write nodes credentials",
"lint": "eslint nodes credentials package.json",
"lintfix": "eslint nodes credentials package.json --fix"
},
"files": [
"dist"
],
"n8n": {
"n8nVersion": "1.0.0",
"credentials": [
"dist/credentials/AwesomeServiceApi.credentials.js"
],
"nodes": [
"dist/nodes/AwesomeService/AwesomeService.node.js"
]
},
"keywords": [
"n8n-community-node-package",
"awesomeservice",
"api",
"automation"
],
"devDependencies": {
// ... development dependencies
}
}
9.2. The Publishing Process
With your package.json
dialed in and code good to go, publishing boils down to a few terminal commands.
- Create an npm Account: If you're new to this, head over to npmjs.com and sign up—it's free. You'll need your username, password, and email ready.
Publish: When you're sure it's solid, go for it.
npm publish
This bundles up your dist
folder (and anything else not ignored in .npmignore
) and pushes it to npm under the name and version from package.json
. Boom—your node is out in the world.
Perform a Dry Run (Highly Recommended): Test the waters first to spot any issues, like forgotten files or config mistakes.
npm publish --dry-run
It'll check everything and list out what files would go in the package, but nothing gets uploaded. Go over that output closely.
Log In to npm from Your Terminal: Run this command—it'll ask for your details.
npm login
9.3. Getting "Verified" by n8n
Putting it on npm gets it to self-hosters, but there's a next level: earning Verified Community Node status from the n8n team after they review it manually.
The Benefits of Verification (The "Why")
- Increased Trust and Visibility: That "Verified" badge tells users the n8n folks have checked it for quality, security, and best practices. It builds serious credibility.
- Availability on n8n Cloud: Here's the big one—only verified nodes work on the official n8n Cloud. That means access to way more users who aren't self-hosting.
- Seamless User Installation: Verified nodes show up right in the main Nodes Panel on the canvas, mixed in with n8n's built-ins. Users can find and add them with one click—no digging through settings.
The Verification Process (The "How")
To get there, submit your node for their review.
- Meet the Prerequisites: Make sure it's up to snuff before sending it in. They'll look at:
- Code Quality: Is it tidy, commented well, and bug-free on the surface?
- Security: Handles creds safely? No sketchy code?
- Adherence to UI/UX Guidelines: Follows the design tips from Part 1, like good naming, resource/operation structure, and clear descriptions?
- Good Documentation: Your
README.md
explains what it does, how to use it, and details the operations?
- Submit for Review: The method might change over time, but usually it's about opening a pull request or issue in a dedicated n8n GitHub repo, or using a form on their site. Check the latest "Submitting community nodes" docs on n8n's site for the current steps.
- The Review: An n8n team member will go through it with their checklist. They might give feedback or ask for tweaks.
- Approval: Pass the review, and it'll join the verified lineup. Then, it starts appearing in the Nodes Panel for all users—cloud and self-hosted alike—and installs effortlessly with a click.
Part 5: The Community Ecosystem: A Maintainer's Guide
Now, you've designed, built, tested, and published your n8n node—that's no small feat. It's a real achievement, and you should feel proud. But here's the thing: putting your node out there isn't the end of the road. It's actually the starting point for your role as a maintainer in the lively n8n community. Now, your node is out in the world, and people are going to rely on it for all sorts of important stuff, like their business automations or personal projects.
In this final part of the guide, we're going to talk about what comes next—your ongoing responsibilities as a maintainer. I'll also give you some key context on how users will find, install, and handle your node. To help you step up effectively, we'll look at things from the user's point of view. That way, you can build some empathy and get a sense of the challenges they might face. After that, we'll dive into what you need to do as a maintainer: the best practices that'll keep your node trustworthy, secure, and truly useful to everyone. Plus, I'll equip you with ways to handle common support questions confidently and clearly. Let's make sure your contribution shines in the community.
Chapter 10: The User Experience with Your Node
To really excel as a maintainer, you need to start by understanding your users. Think about it: How do they even discover your node in the first place? What's the installation process like for them? And what kinds of risks are they exposing themselves to by deciding to trust and run your code? In this chapter, I'll walk you through the user's journey, putting you right in their shoes so you can see the full picture.
10.1. Installation and Management
The very first touchpoint a user has with your node is when they install it. But that experience isn't the same for everyone—it changes a lot based on whether your node is a regular community one from npm, a verified one, or if the user has some specialized setup for their n8n instance. Let's break this down into the three main ways users can get your node up and running.
The User's Three Paths to Installation:
- The GUI Install (for unverified npm nodes)
Who it's for: This is the go-to method for owners of self-hosted n8n instances who want to grab a community node straight from the npm registry. It's straightforward for most folks in this setup.
The User's Process:- The user heads over to Settings > Community Nodes in their n8n instance.
- They click the Install button.
- They're prompted to find a node, so they click Browse, which pops open a new browser tab to an npm search results page. It's pre-filtered to show packages that have the
n8n-community-node-package
keyword. - The user looks through this list, spots your node, and jots down its exact package name (for example,
n8n-nodes-awesomeservice
). - They switch back to the n8n UI and paste that name into the "Enter npm package name" field. If they're after a specific version, they can add it on (like
[email protected]
). - At this point, they see a checkbox along with a pretty serious warning. They have to actively agree to the risks of installing unverified code before moving forward.
- Finally, they click Install. n8n takes care of installing the package, and just like that, your node shows up in the nodes panel, ready to use.
- The Verified Node Install (The Premium Experience)
Who it's for: This is available to all users, including those on n8n Cloud, as long as the instance owner has installed a node that's been officially verified by the n8n team. It's designed to feel smooth and reliable.
The User's Process:- The user is working on the n8n canvas and opens the Nodes Panel (either by clicking the
+
icon or hittingTab
). - They start typing in a search for what they need, say "weather" or "CRM".
- Down at the bottom of the search results, there's a new section called "More from the community". That's where your verified node will pop up.
- They click on your node, and a detailed view comes up, displaying its icon, description, and the operations it supports.
- They hit the big Install button. The node installs right away, and they can drag it straight onto the canvas.
The Maintainer's Insight: This whole process is seamless, trustworthy, and super easy to discover—which is exactly why getting your node verified is such a game-changer. It's the biggest incentive to go through that verification step.
- The user is working on the n8n canvas and opens the Nodes Panel (either by clicking the
- The Manual Command-Line (CLI) Install
Who it's for: This is aimed at more advanced users on self-hosted instances, especially those running n8n in "queue mode" or needing to install private npm packages that aren't on the public registry. It's a bit more hands-on.
The User's Process:- The user opens a terminal and jumps into their running n8n Docker container's shell with this command:
docker exec -it n8n sh
. - They make a special directory if it's not already there:
mkdir -p ~/.n8n/nodes
. - They change into that directory:
cd ~/.n8n/nodes
. - Then, they use npm to install your package directly:
npm install n8n-nodes-awesomeservice
. - To get the node loaded, they have to fully restart their n8n instance.
- The user opens a terminal and jumps into their running n8n Docker container's shell with this command:
The Risks Your Users Are Taking
When someone installs your unverified community node, n8n doesn't hold back—it spells out the risks clearly. As the maintainer, it's crucial that you appreciate the level of trust they're putting in you. Here's what they're potentially facing:
- System Security: Your community node gets full access to the file system and network of the machine where n8n is running. If there was anything malicious in it, it could read sensitive files, delete data, or reach out to external servers. That's why transparency is key: make sure your code is secure and does exactly what it says on the tin.
- Data Security: In a workflow, any node can see all the data passing through it, including credentials from earlier nodes and any sensitive info being handled. Users are counting on your node to use that data only for its intended purpose and not ship it off to some unauthorized place.
- Breaking Changes: As the developer, you might roll out updates that aren't compatible with older versions. This could make a user's existing workflows crash out of nowhere after they update. This one's a big deal—it's common enough that we'll give it its own section to unpack fully.
User Management of Your Node
After installation, users handle your node from the Settings > Community Nodes page. Here's what they can do:
- Uninstalling: They can completely remove your node package.
- Upgrading: If you push a new version to npm, an "Update" button shows up, letting them upgrade to the latest with just one click.
- Downgrading / Installing a Specific Version: If an upgrade breaks something, they can uninstall your node and then reinstall it, but this time specifying an older version (for example,
[email protected]
) to roll things back.
10.2. The Critical Impact of Breaking Changes
Out of all the risks users face, breaking changes are the most frequent and can be incredibly frustrating. As a maintainer, your top job is to handle this through smart communication and careful versioning. Let's get clear on what this means and why it matters so much.
What is a Breaking Change?
A breaking change is basically any tweak you make to your node that causes a previously working workflow to fail or act differently. It's a disruption to what users expect.
Here are some examples of breaking changes:
- Renaming a field's internal
name
(for instance, switching fromfolderId
tofolderIdentifier
). - Changing the data type of a field (like going from a string to a number).
- Removing an operation or resource that was there before.
- Fundamentally altering the structure of the JSON data that your node outputs.
The User's Nightmare Scenario:
Picture this: A user has set up a vital business workflow using your node to handle payments. It's been chugging along perfectly for months. Then, you release a new version with what you think is a "small improvement"—maybe just renaming an output field. The user spots the "Update" button in their n8n instance and figures it's probably a quick bug fix, so they click it. Boom—suddenly, all their payment workflows grind to a halt because a downstream node is hunting for an output field that doesn't exist anymore under the old name.
Your Responsibility: Communication Through Versioning
To avoid turning that nightmare into reality, you need to stick rigorously to Semantic Versioning (SemVer). That version number in the format MAJOR.MINOR.PATCH
is like a promise to your users—it's your way of signaling what's inside.
- PATCH (e.g., 0.1.0 -> 0.1.1): This means you're only including backward-compatible bug fixes. It's safe for users to upgrade without worry.
- MINOR (e.g., 0.1.1 -> 0.2.0): Here, you're adding new features, but everything is fully backward-compatible. Again, it's safe for users to go ahead and update.
- MAJOR (e.g., 0.2.0 -> 1.0.0): This is your red flag. By bumping the first number, you're straight-up warning users: "Heads up—this has breaking changes. If you upgrade, you might need to tweak your existing workflows manually to match the updates."
Always make sure to document any breaking changes clearly in your project's README.md
file or even better, in a dedicated CHANGELOG.md
. This kind of professional approach builds real trust. It gives users a heads-up so they can plan time to adapt, instead of getting blindsided.
Chapter 11: Your Responsibilities as a Node Maintainer
Stepping into the role of a community node maintainer means committing to quality and security over the long haul. Your node isn't just your creation—it's a piece of the broader n8n ecosystem, and how you handle it reflects on everyone involved. By following these principles, you'll make sure your contribution stays positive, reliable, and well-regarded.
11.1. Avoiding the Blocklist
n8n keeps a "blocklist" of community nodes that are straight-up banned from installation. If your node ends up on there, users will hit an error when they try to add it. Nodes get added for two main reasons, and understanding them will help you steer clear.
- The Node is Intentionally Malicious:
This is the gravest issue. If a node is caught doing harmful stuff on purpose, it'll be blocklisted immediately and permanently. Examples include:- Stealing or exfiltrating user credentials or workflow data to an unauthorized server.
- Executing arbitrary shell commands that could compromise the host system.
- Acting as a proxy for spam, denial-of-service attacks, or other nefarious activities.
- Your Responsibility: Keep things transparent. Remember, your node's code is open source. Only include code that does exactly what your node's description promises.
- The Node is of Harmfully Low Quality:
Even if there's no bad intent, a node can be so badly built that it threatens the stability of a user's entire n8n setup. This might involve:- Causing System Crashes: Things like unhandled errors, infinite loops, or eating up too much memory, which could crash the whole n8n instance.
- Data Loss: Bugs in the node's logic that might accidentally delete or corrupt data in the connected service.
- Being Abandoned and Broken: If the node depends on an API that's since changed, and you don't update it, leaving it useless forever.
- Your Responsibility: Stay on top of your game as a developer. Test your node rigorously with various inputs. Use the solid error-handling patterns we covered in Part 3. Keep your dependencies current. If you decide you can't maintain it anymore, archive the repository and mark the npm package as deprecated—that way, users know its status.
If you ever think your node got blocklisted by error, you can contact the n8n team at [email protected]
to talk it through.
11.2. Troubleshooting Common User Issues
As a maintainer, you'll probably get bug reports or questions from users now and then. Having quick, clear answers ready for the usual problems will save time for everyone. Let's focus on the most frequent one—it's a classic.
The Most Common Problem: "Error: Missing packages" in Docker
- The Scenario: A user who's running n8n in Docker installs your node through the GUI, and it works great at first. But then they upgrade their n8n version by pulling a new
n8nio/n8n
image and recreating the container. When the fresh n8n starts up, their workflows break, and the logs complain about a missing package—yours. - The Technical Cause: This happens because Docker containers are ephemeral by default. That means any changes inside the container's filesystem during runtime—like installing your node via the GUI, which runs
npm install
inside—get wiped out when the container is deleted. So, when the user spins up a new container from a fresh image for the upgrade, the directory with your node is just gone.
Your Ready-Made Solution: You can guide the user through two options, laying out the pros and cons clearly.Solution 1: Persist the nodes
Directory (The Best Practice)
"The best and most robust way to solve this is to ensure that the directory where community nodes are installed is persisted outside of the container. This is done by mounting a Docker volume.Please add the following line to the volumes
section of your n8n service in your docker-compose.yml
file:
volumes:
- ~/.n8n:/root/.n8n
- ./n8n-nodes:/root/.n8n/nodes # Add this line
This tells Docker to create a folder named n8n-nodes
on your host machine (next to your docker-compose.yml
) and map it to the /root/.n8n/nodes
directory inside the container. After adding this line and restarting your container (docker-compose up -d
), please reinstall the node one last time. From now on, the node's files will be stored safely on your host machine and will survive all future container restarts and n8n upgrades."Solution 2: Automatically Reinstall Missing Packages (The Workaround)
"If you are unable to modify your Docker volumes, you can instruct n8n to automatically reinstall any missing packages on startup.Please add the following line to the environment
section of your n8n service in your docker-compose.yml
file:
environment:
- N8N_REINSTALL_MISSING_PACKAGES=true
After restarting, n8n will detect that the node package is missing and will attempt to reinstall it from npm automatically. Please be aware that this is a workaround. The official n8n documentation notes that this option can significantly increase the startup time of your instance and may cause initial health checks to fail while the reinstallation is in progress. The volume persistence method is the recommended solution."