Custom Nodes for Editor
This guide explains how to create custom node types for the Omega Flow visual editor.
Complete Custom Node Implementation
A complete custom node implementation requires both:
- Editor-side: NodeTypeDefinition (this guide) - handles visual representation
- Engine-side: NodeModel class (see Custom Nodes for Engine) - handles execution logic
Both parts use the same type identifier to connect them.
Overview
Each node type in the editor consists of:
- NodeTypeDefinition - Metadata and configuration
- ViewComponent - Renders the node on the canvas
- DetailComponent - Renders the properties panel
NodeTypeDefinition
The NodeTypeDefinition interface defines a complete node type:
interface NodeTypeDefinition {
// Unique identifier (e.g., "MyCustomNode")
type: string;
// Display name in the nodes panel
label: string;
// Optional description/tooltip
description?: string;
// Optional icon component
Icon?: ComponentType<{ size?: number }>;
// Initial data when node is created
defaultData: Record<string, unknown>;
// Component rendered on the canvas
ViewComponent: ComponentType<NodeViewProps>;
// Component rendered in the detail panel
DetailComponent: ComponentType<NodeDetailProps>;
}Connection handles (input/output ports) are defined in the ViewComponent using BaseNodeView. See Step 3 for details.
Creating a Custom Node
Let's create a custom "SendEmail" node step by step.
Step 1: Define the Node Data
First, define the data structure for your node:
interface SendEmailData {
to: string;
subject: string;
body: string;
template?: string;
}Step 2: Create the Icon Component
function SendEmailIcon({ size = 24 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
);
}Step 3: Create the View Component
The view component renders on the canvas. Use BaseNodeView for consistent styling:
import { BaseNodeView, type NodeViewProps } from "@omega-flow/editor";
const SEND_EMAIL_COLOR = "#E91E63";
function SendEmailNodeView({ id, data, selected }: NodeViewProps) {
const nodeData = data as SendEmailData;
return (
<BaseNodeView
id={id}
data={data as Record<string, unknown>}
selected={selected}
label="Send Email"
color={SEND_EMAIL_COLOR}
icon={<SendEmailIcon size={16} />}
sourceHandles={[{ id: "output", label: "Next" }]}
targetHandles={[{ id: "input", label: "In" }]}
>
{nodeData.to ? (
<span>To: {nodeData.to}</span>
) : (
<span style={{ opacity: 0.5 }}>No recipient set</span>
)}
</BaseNodeView>
);
}Step 4: Create the Detail Component
The detail component renders in the properties panel. Use primitives for form fields:
import {
TextField,
TextAreaField,
FieldGroup,
type NodeDetailProps,
} from "@omega-flow/editor";
function SendEmailNodeDetail({ node, onChange }: NodeDetailProps) {
const data = node.data as SendEmailData;
return (
<div>
<TextField
label="To"
value={data.to || ""}
onChange={(value) => onChange({ ...data, to: value })}
placeholder="recipient@example.com"
/>
<TextField
label="Subject"
value={data.subject || ""}
onChange={(value) => onChange({ ...data, subject: value })}
placeholder="Email subject"
/>
<TextAreaField
label="Body"
value={data.body || ""}
onChange={(value) => onChange({ ...data, body: value })}
rows={4}
placeholder="Email body content..."
/>
<FieldGroup label="Advanced" collapsible defaultCollapsed>
<TextField
label="Template ID"
value={data.template || ""}
onChange={(value) => onChange({ ...data, template: value })}
placeholder="Optional template ID"
hint="Use a template instead of body content"
/>
</FieldGroup>
</div>
);
}Step 5: Create the Node Definition
import type { NodeTypeDefinition } from "@omega-flow/editor";
const sendEmailNodeType: NodeTypeDefinition = {
type: "SendEmail",
label: "Send Email",
description: "Sends an email to a recipient",
Icon: SendEmailIcon,
defaultData: {
to: "",
subject: "",
body: "",
template: "",
},
ViewComponent: SendEmailNodeView,
DetailComponent: SendEmailNodeDetail,
};Step 6: Register the Node Type
Pass your custom node types to WorkflowEditor. Use mergeNodeTypes to combine the defaults with your own — it dedupes by type, so you can also override a built-in by passing a definition that reuses its type key:
import {
WorkflowEditor,
defaultNodeTypes,
mergeNodeTypes,
} from "@omega-flow/editor";
const allNodeTypes = mergeNodeTypes(defaultNodeTypes, [sendEmailNodeType]);
function App() {
return (
<WorkflowEditor nodeTypes={allNodeTypes}>
<EditorCanvas />
</WorkflowEditor>
);
}A plain spread ([...defaultNodeTypes, sendEmailNodeType]) also works for the simple "add only" case.
Complete Example
Here's the full custom node in one file:
import type { NodeTypeDefinition, NodeViewProps, NodeDetailProps } from "@omega-flow/editor";
import { BaseNodeView, TextField, TextAreaField, FieldGroup } from "@omega-flow/editor";
// Data interface
interface SendEmailData {
to: string;
subject: string;
body: string;
template?: string;
}
// Constants
const SEND_EMAIL_COLOR = "#E91E63";
// Icon component
function SendEmailIcon({ size = 24 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
);
}
// View component (canvas)
function SendEmailNodeView({ id, data, selected }: NodeViewProps) {
const nodeData = data as SendEmailData;
return (
<BaseNodeView
id={id}
data={data as Record<string, unknown>}
selected={selected}
label="Send Email"
color={SEND_EMAIL_COLOR}
icon={<SendEmailIcon size={16} />}
sourceHandles={[{ id: "output", label: "Next" }]}
targetHandles={[{ id: "input", label: "In" }]}
>
{nodeData.to ? (
<span>To: {nodeData.to}</span>
) : (
<span style={{ opacity: 0.5 }}>No recipient set</span>
)}
</BaseNodeView>
);
}
// Detail component (properties panel)
function SendEmailNodeDetail({ node, onChange }: NodeDetailProps) {
const data = node.data as SendEmailData;
return (
<div>
<TextField
label="To"
value={data.to || ""}
onChange={(value) => onChange({ ...data, to: value })}
placeholder="recipient@example.com"
/>
<TextField
label="Subject"
value={data.subject || ""}
onChange={(value) => onChange({ ...data, subject: value })}
placeholder="Email subject"
/>
<TextAreaField
label="Body"
value={data.body || ""}
onChange={(value) => onChange({ ...data, body: value })}
rows={4}
placeholder="Email body content..."
/>
<FieldGroup label="Advanced" collapsible defaultCollapsed>
<TextField
label="Template ID"
value={data.template || ""}
onChange={(value) => onChange({ ...data, template: value })}
hint="Use a template instead of body content"
/>
</FieldGroup>
</div>
);
}
// Node type definition
export const sendEmailNodeType: NodeTypeDefinition = {
type: "SendEmail",
label: "Send Email",
description: "Sends an email to a recipient",
Icon: SendEmailIcon,
defaultData: {
to: "",
subject: "",
body: "",
template: "",
},
ViewComponent: SendEmailNodeView,
DetailComponent: SendEmailNodeDetail,
};Multiple Output Handles
For nodes that need branching (like conditions), define multiple source handles in the ViewComponent:
function BranchNodeView({ id, data, selected }: NodeViewProps) {
return (
<BaseNodeView
id={id}
data={data as Record<string, unknown>}
selected={selected}
label="Branch"
color="#FF9800"
sourceHandles={[
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
{ id: "error", label: "Error" },
]}
targetHandles={[{ id: "input", label: "In" }]}
>
{/* Node content */}
</BaseNodeView>
);
}Nodes Without Inputs (Start Nodes)
For nodes that start a workflow (like Trigger), omit target handles:
function StartNodeView({ id, data, selected }: NodeViewProps) {
return (
<BaseNodeView
id={id}
data={data as Record<string, unknown>}
selected={selected}
label="Start"
color="#4CAF50"
sourceHandles={[{ id: "output", label: "Next" }]}
targetHandles={[]} // No inputs
>
{/* Node content */}
</BaseNodeView>
);
}Nodes Without Outputs (End Nodes)
For nodes that terminate a workflow (like Exit), omit source handles:
function EndNodeView({ id, data, selected }: NodeViewProps) {
return (
<BaseNodeView
id={id}
data={data as Record<string, unknown>}
selected={selected}
label="End"
color="#F44336"
sourceHandles={[]} // No outputs
targetHandles={[{ id: "input", label: "In" }]}
>
{/* Node content */}
</BaseNodeView>
);
}Using Built-in Views and Details
You can extend or reuse built-in components:
import {
TriggerNodeView,
TriggerNodeDetail,
ActionNodeView,
ActionNodeDetail,
ConditionNodeView,
ConditionNodeDetail,
WaitNodeView,
WaitNodeDetail,
ExitNodeView,
ExitNodeDetail,
TriggerOrTimeoutNodeView,
TriggerOrTimeoutNodeDetail,
} from "@omega-flow/editor";Available Primitives
Use these primitives for building detail components:
| Primitive | Description |
|---|---|
TextField | Single-line text input |
NumberField | Numeric input with min/max/step |
SelectField | Dropdown select |
CheckboxField | Boolean toggle |
TextAreaField | Multi-line text input |
DurationField | Duration with unit selection (ms/s/min/h) |
JsonField | JSON editor with validation |
FieldGroup | Groups fields with optional collapse |
Field | Base wrapper for custom fields |
See the Primitives API for details.
Best Practices
- Use TypeScript - Define interfaces for your node data
- Consistent colors - Use distinct colors for different node types
- Clear icons - Use simple, recognizable icons
- Meaningful defaults - Set sensible default values
- Helpful hints - Add hint text to fields for guidance
- Validation - Validate data in the detail component
- Collapsible groups - Group advanced options to reduce clutter
- Use
useTranslation- Use theuseTranslation()hook for all user-facing strings so your nodes support localization. See Localization for details.
Working Example
apps/sampleApp/src/nodes/storeTrigger.tsx shows a complete custom node — a "Store Trigger" with a SelectField of store actions in its detail panel — registered alongside the defaults via mergeNodeTypes. Pair it with its engine counterpart in apps/sampleServer/src/nodes/StoreTriggerModel.ts (see Custom Nodes (Engine)) for an end-to-end reference.
Next Steps
- Create the engine-side custom node for execution logic
- Explore Primitives API for all form fields
- See Types Reference for all TypeScript types
- Check the Hooks API for programmatic control
- Read the Localization Guide for translating custom nodes