A visual workflow builder built with Next.js and ReactFlow that allows you to create, edit, and execute data processing workflows through an intuitive drag-and-drop interface.
- Overview
- Key Technologies
- Getting Started
- Project Structure
- Core Concepts
- Node Types
- Edge Types
- Drag & Drop Implementation
- State Management
- Workflow Execution
- Important Implementation Details
This project is a visual workflow builder that demonstrates how to use ReactFlow (XYFlow) to create node-based workflows. Users can:
- Drag nodes from a sidebar onto a canvas
- Connect nodes to define data flow
- Configure each node with specific parameters
- Execute workflows and see real-time results
- Save and load workflows from a database
What is ReactFlow/XYFlow?
ReactFlow is a library for building node-based editors and interactive diagrams. It provides:
- Canvas with pan, zoom, and minimap
- Custom node and edge rendering
- Connection handling and validation
- Built-in controls and utilities
| Technology | Purpose | Version |
|---|---|---|
| Next.js | React framework with App Router | 16.x |
| ReactFlow (@xyflow/react) | Node-based canvas and workflow visualization | 12.x |
| Zustand | Lightweight state management | 5.x |
| Zod | Runtime schema validation | 4.x |
| Tailwind CSS | Styling framework | 4.x |
| Prisma | Database ORM | 7.x |
| shadcn/ui | UI component library | Latest |
- Node.js 18+ or Bun
- PostgreSQL (for workflow persistence)
-
Clone and install dependencies:
bun install # or npm install -
Set up environment variables:
cp .env.example .env # Edit .env with your database URL -
Run database migrations:
bunx prisma migrate dev
-
Start the development server:
bun dev # or npm run dev
src/
βββ app/ # Next.js App Router
β βββ page.tsx # Main workflow builder page
β βββ api/workflows/ # API routes for workflow CRUD
βββ components/
β βββ ui/ # shadcn UI components
β βββ workflow/ # Workflow builder components
β βββ index.tsx # Main WorkflowBuilder component
β βββ canvas.tsx # ReactFlow canvas (drag/drop target)
β βββ nodes.tsx # Custom node components
β βββ node-drawer.tsx # Sidebar with draggable nodes
β βββ node-editor.tsx # Right panel for editing nodes
β βββ toolbar.tsx # Top toolbar with actions
βββ lib/
βββ workflow/
βββ types.ts # TypeScript types & Zod schemas
βββ store.ts # Zustand state management
βββ executor.worker.ts # Web Worker for execution
βββ use-executor.ts # Hook to run workflows
βββ sample-workflows.ts # Pre-built example workflows
Nodes are the building blocks of a workflow. Each node has:
- id: Unique identifier
- type: Determines appearance and behavior
- position: { x, y } coordinates on canvas
- data: Custom data specific to the node type
const node: WorkflowNode = {
id: "node_1",
type: "httpRequest",
position: { x: 100, y: 200 },
data: {
nodeType: "httpRequest",
label: "Fetch API",
url: "https://api.example.com/data",
method: "GET",
},
};Edges define connections between nodes (data flow). Each edge has:
- source: ID of the starting node
- target: ID of the destination node
- sourceHandle: Which output on the source node
- targetHandle: Which input on the target node
- type: Visual style ("smoothstep", "step", "straight", "bezier")
const edge: WorkflowEdge = {
id: "edge_1",
source: "node_1",
target: "node_2",
sourceHandle: "output",
targetHandle: "input",
type: "step", // Right-angle connections
};Custom node components must be registered with ReactFlow:
const nodeTypes = {
start: StartNode,
httpRequest: HttpRequestNode,
transform: TransformNode,
// ... other node types
};
<ReactFlow nodeTypes={nodeTypes} ... />Handles are connection points on nodes:
- Source handles: Where edges start (outputs)
- Target handles: Where edges end (inputs)
- Positioned using
Position.Top/Right/Bottom/Left
<Handle
type="source"
position={Position.Right}
id="output"
/>| Node Type | Icon | Purpose | Key Properties |
|---|---|---|---|
| Start | Workflow entry point | inputData - Initial data |
|
| HTTP Request | π | API calls | method, url, headers, body |
| Transform | π» | Data transformation | expression - JavaScript code |
| Condition | π | Conditional branching | condition - Boolean expression |
| Delay | β° | Pause execution | duration - Milliseconds |
| Output | π | Display results | format - "json" or "text" |
| Merge | π | Combine multiple inputs | strategy - How to merge |
| Set Variable | π | Store data | variableName, value |
- Start, HTTP, Transform, Delay, Output: 1 input (left), 1 output (right)
- Condition: 1 input (left), 2 outputs (right) - "true" (green) and "false" (red)
- Merge: Multiple inputs (left), 1 output (right)
ReactFlow supports multiple edge visual styles:
| Type | Appearance | Use Case |
|---|---|---|
| smoothstep | Smooth curved lines | Default, clean look |
| step | Right-angle (90Β°) lines | Technical diagrams |
| straight | Direct lines | Simple connections |
| bezier | Classic curves | Similar to smoothstep |
Change edge type in workflow definitions:
edges: [
{
id: "edge_1",
source: "start",
target: "http",
type: "step", // Try: "smoothstep", "straight", "bezier"
},
];The drag-and-drop system uses the HTML5 Drag & Drop API:
const handleDragStart = (event: DragEvent) => {
// Store node type in dataTransfer
event.dataTransfer.setData(
"application/reactflow-nodetype",
nodeType
);
event.dataTransfer.effectAllowed = "move";
};
<div draggable onDragStart={handleDragStart}>
{/* Node tile */}
</div>Key Point: "application/reactflow-nodetype" is a custom MIME type (just a string key) used to identify what's being dragged. It's NOT a TypeScript type or a pre-defined standardβyou can use any unique string.
const handleDragOver = (event: DragEvent) => {
event.preventDefault(); // Required to allow dropping
event.dataTransfer.dropEffect = "move";
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
// Retrieve the node type
const nodeType = event.dataTransfer.getData("application/reactflow-nodetype");
// Convert screen position to flow position
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Create new node
addNode(nodeType, position);
};Problem: By default, shadcn's Sheet component uses a modal overlay that blocks the canvas, preventing drops.
Solution: Use modal={false} and showOverlay={false}:
<Sheet modal={false}>
<SheetContent showOverlay={false}>
{/* Draggable nodes */}
</SheetContent>
</Sheet>This allows the drawer to stay open while you drag-and-drop onto the canvas!
The workflow state is managed with Zustand, a simple and efficient state library:
Key State:
interface WorkflowState {
// Workflow data
nodes: WorkflowNode[];
edges: WorkflowEdge[];
workflowName: string;
workflowDescription: string;
// UI state
isDrawerOpen: boolean;
selectedNodeId: string | null;
// Execution state
executionStatus: ExecutionStatus;
nodeResults: Map<string, NodeExecutionResult>;
// Actions
addNode: (nodeType, position) => void;
updateNode: (nodeId, data) => void;
deleteNode: (nodeId) => void;
// ... more actions
}Using the Store:
function MyComponent() {
const nodes = useWorkflowStore((state) => state.nodes);
const addNode = useWorkflowStore((state) => state.addNode);
// Use state and actions...
}ReactFlow requires two providers:
<ReactFlowProvider>
<TooltipProvider>
<WorkflowBuilder />
</TooltipProvider>
</ReactFlowProvider>- ReactFlowProvider: Enables
useReactFlow()hook - TooltipProvider: For shadcn tooltips (optional)
- User clicks "Run" β Store dispatches
executeWorkflow() - State transitions: idle β running
- Web Worker spawned (keeps UI responsive)
- Nodes executed in order (topological sort)
- Results stored in
nodeResultsMap - UI updates in real-time as each node completes
Before execution, workflows are validated:
- β Must have exactly one Start node
- β All nodes must be reachable from Start
- β No cycles (infinite loops)
- β All required node properties filled
During execution, each node receives data from its inputs:
// In a Transform node expression:
const result = data.email.toLowerCase();
// 'data' = output from previous nodeFor Condition nodes:
condition: "data.age >= 18";
// Evaluates to true or false β routes to correct output// β
Correct
<ReactFlowProvider>
<MyComponent /> {/* Can use useReactFlow() */}
</ReactFlowProvider>
// β Wrong - useReactFlow() will fail
<MyComponent />type CustomNodeProps = {
id: string;
data: YourNodeData;
selected: boolean;
};
function CustomNode({ id, data, selected }: CustomNodeProps) {
// Your node UI
}// Node 1 (source)
<Handle type="source" id="output" />
// Node 2 (target)
<Handle type="target" id="input" />
// Edge connecting them
{ sourceHandle: "output", targetHandle: "input" }Always convert screen coordinates to flow coordinates:
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});This accounts for canvas pan/zoom.
All node data is validated with Zod schemas:
const result = HttpRequestNodeDataSchema.safeParse(data);
if (!result.success) {
console.error(result.error);
}Zustand uses Immer middleware for clean state updates:
// Without Immer (complex)
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...newData } } : n,
),
}));
// With Immer (simple)
set((state) => {
const node = state.nodes.find((n) => n.id === id);
if (node) node.data = { ...node.data, ...newData };
});The easiest way to deploy is using Vercel:
vercel deployMake sure to:
- Set up PostgreSQL database
- Configure environment variables
- Run migrations:
bunx prisma migrate deploy
Built with β€οΈ using ReactFlow, Next.js, and Zustand