How I built production-ready custom TypeScript nodes for N8N to extend automation capabilities beyond standard integrations.
While N8N offers 6000+ workflow templates and hundreds of pre-built nodes, sometimes you need custom functionality that doesn't exist out of the box. At SkinSeoul, I found myself building custom TypeScript Code nodes to handle unique business logic that standard nodes couldn't solve.
Before diving into custom development, ask yourself:
If the answer to any of these is "yes," custom nodes might be your solution.
Clone your N8N instance:
git clone https://github.com/n8n-io/n8n.git
cd n8nInstall dependencies:
pnpm add
Start in development mode:
pnpm dev
custom-nodes/
├── src/
│ ├── nodes/
│ │ └── CustomProcessor/
│ │ ├── CustomProcessor.node.ts
│ │ └── CustomProcessor.node.json
│ └── credentials/
├── package.json
└── tsconfig.json
At SkinSeoul, I built a custom node for advanced order validation that checked multiple data sources before processing.
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from "n8n-workflow";
export class OrderValidator implements INodeType {
description: INodeTypeDescription = {
displayName: "Order Validator",
name: "orderValidator",
group: ["transform"],
version: 1,
description: "Validates order data against multiple criteria",
defaults: {
name: "Order Validator",
},
inputs: ["main"],
outputs: ["main", "invalid"],
properties: [
{
displayName: "Validation Rules",
name: "rules",
type: "collection",
default: {},
options: [
{
displayName: "Check Currency",
name: "checkCurrency",
type: "boolean",
default: true,
},
{
displayName: "Minimum Amount",
name: "minAmount",
type: "number",
default: 0,
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const validOrders: INodeExecutionData[] = [];
const invalidOrders: INodeExecutionData[] = [];
const rules = this.getNodeParameter("rules", 0) as {
checkCurrency: boolean;
minAmount: number;
};
for (let i = 0; i < items.length; i++) {
const order = items[i].json;
try {
// Currency validation
if (rules.checkCurrency) {
const validCurrencies = ["SGD", "AED", "JPY", "USD"];
if (!validCurrencies.includes(order.currency as string)) {
throw new Error(`Invalid currency: ${order.currency}`);
}
}
// Amount validation
if (rules.minAmount && (order.total as number) < rules.minAmount) {
throw new Error(`Order below minimum amount`);
}
validOrders.push(items[i]);
} catch (error) {
invalidOrders.push({
json: {
...order,
error: error.message,
},
});
}
}
return [validOrders, invalidOrders];
}
}When processing large datasets, batch operations are crucial:
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const batchSize = 50;
const results: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
// Process batch in parallel
const batchResults = await Promise.all(
batch.map(item => this.processItem(item))
);
results.push(...batchResults);
}
return [results];
}Integrating with custom APIs requires proper error handling:
private async fetchExternalData(
this: IExecuteFunctions,
endpoint: string
): Promise<any> {
const credentials = await this.getCredentials('customApi');
try {
const response = await this.helpers.request({
method: 'GET',
url: `${credentials.baseUrl}${endpoint}`,
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'Content-Type': 'application/json',
},
json: true,
});
return response;
} catch (error) {
if (error.statusCode === 429) {
// Rate limiting - implement exponential backoff
await this.helpers.sleep(5000);
return this.fetchExternalData(endpoint);
}
throw error;
}
}For workflows that need to maintain state across executions:
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
// Get workflow static data
const staticData = this.getWorkflowStaticData('node');
// Initialize counter if doesn't exist
if (staticData.processedOrders === undefined) {
staticData.processedOrders = 0;
}
// Process items
const items = this.getInputData();
staticData.processedOrders += items.length;
// Add metadata to output
return [items.map(item => ({
json: {
...item.json,
totalProcessed: staticData.processedOrders,
},
}))];
}import { NodeOperationError } from "n8n-workflow";
import { OrderValidator } from "../OrderValidator.node";
describe("OrderValidator", () => {
it("should validate correct orders", async () => {
const node = new OrderValidator();
const mockContext = createMockExecuteContext([
{ json: { id: 1, currency: "SGD", total: 100 } },
]);
const result = await node.execute.call(mockContext);
expect(result[0]).toHaveLength(1);
expect(result[1]).toHaveLength(0);
});
it("should catch invalid currency", async () => {
const node = new OrderValidator();
const mockContext = createMockExecuteContext([
{ json: { id: 2, currency: "INVALID", total: 100 } },
]);
const result = await node.execute.call(mockContext);
expect(result[0]).toHaveLength(0);
expect(result[1]).toHaveLength(1);
});
});console.log("Processing item:", JSON.stringify(item, null, 2));Before integrating with N8N, test your logic with Postman to validate API responses and data structures.
throw new NodeOperationError(
this.getNode(),
`Failed to process order ${orderId}: ${error.message}`,
{ itemIndex: i }
);Promise.all() for parallel processingfor (const item of items) {
const result = await processItem(item); // Slow!
results.push(result);
}const results = await Promise.all(
items.map((item) => processItem(item)) // Fast!
);Building custom nodes allowed us to:
I'm exploring:
Custom TypeScript nodes transformed how we build automation at SkinSeoul, enabling complex business logic that standard nodes couldn't handle.
Have you built custom N8N nodes? What challenges did you face? Share your experiences below!