Skip to main content
Back to Blog
Tutorials
4 min read
December 16, 2024

How to Build a Workflow Automation System in Next.js

Build a workflow automation system in Next.js with configurable steps, conditional branching, approval flows, and status tracking.

Ryel Banfield

Founder & Lead Developer

Workflow automation removes manual steps from business processes. Here is how to build a configurable system with approval flows.

Define Workflow Types

// types/workflow.ts
export type StepType = "action" | "condition" | "approval" | "notification" | "delay" | "webhook";
export type WorkflowStatus = "draft" | "active" | "paused" | "completed" | "failed";
export type StepStatus = "pending" | "in-progress" | "completed" | "skipped" | "failed";

export interface WorkflowStep {
  id: string;
  type: StepType;
  name: string;
  config: Record<string, unknown>;
  nextStepId?: string;
  onSuccess?: string;
  onFailure?: string;
}

export interface Workflow {
  id: string;
  name: string;
  description?: string;
  trigger: {
    type: "manual" | "webhook" | "schedule" | "event";
    config: Record<string, unknown>;
  };
  steps: WorkflowStep[];
  status: WorkflowStatus;
  createdAt: string;
  updatedAt: string;
}

export interface WorkflowExecution {
  id: string;
  workflowId: string;
  status: WorkflowStatus;
  currentStepId: string | null;
  context: Record<string, unknown>;
  stepResults: Record<string, StepResult>;
  startedAt: string;
  completedAt?: string;
}

export interface StepResult {
  status: StepStatus;
  output?: unknown;
  error?: string;
  startedAt: string;
  completedAt?: string;
}

Workflow Engine

// lib/workflow/engine.ts
import type {
  Workflow,
  WorkflowExecution,
  WorkflowStep,
  StepResult,
} from "@/types/workflow";

type StepHandler = (
  step: WorkflowStep,
  context: Record<string, unknown>
) => Promise<{ success: boolean; output?: unknown; error?: string }>;

const stepHandlers: Record<string, StepHandler> = {
  action: async (step, context) => {
    const actionFn = actions[step.config.action as string];
    if (!actionFn) return { success: false, error: `Unknown action: ${step.config.action}` };
    const output = await actionFn(step.config, context);
    return { success: true, output };
  },

  condition: async (step, context) => {
    const field = step.config.field as string;
    const operator = step.config.operator as string;
    const value = step.config.value;
    const actual = context[field];

    let result = false;
    switch (operator) {
      case "equals": result = actual === value; break;
      case "not_equals": result = actual !== value; break;
      case "greater_than": result = (actual as number) > (value as number); break;
      case "less_than": result = (actual as number) < (value as number); break;
      case "contains": result = String(actual).includes(String(value)); break;
    }

    return { success: true, output: { result } };
  },

  approval: async (step, _context) => {
    // Create approval request and wait
    // In practice, this would create a record and pause execution
    return {
      success: true,
      output: { status: "pending", approver: step.config.approver },
    };
  },

  notification: async (step, context) => {
    const template = step.config.template as string;
    const recipient = step.config.recipient as string;

    await sendNotification({
      to: resolveValue(recipient, context) as string,
      template,
      data: context,
    });

    return { success: true };
  },

  webhook: async (step, context) => {
    const url = step.config.url as string;
    const method = (step.config.method as string) ?? "POST";

    const response = await fetch(url, {
      method,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(context),
    });

    if (!response.ok) {
      return { success: false, error: `Webhook returned ${response.status}` };
    }

    const output = await response.json();
    return { success: true, output };
  },

  delay: async (step) => {
    const duration = step.config.duration as number; // milliseconds
    // In production, schedule a delayed job instead of sleeping
    return { success: true, output: { delayed: duration } };
  },
};

export class WorkflowEngine {
  async execute(
    workflow: Workflow,
    initialContext: Record<string, unknown> = {}
  ): Promise<WorkflowExecution> {
    const execution: WorkflowExecution = {
      id: crypto.randomUUID(),
      workflowId: workflow.id,
      status: "active",
      currentStepId: workflow.steps[0]?.id ?? null,
      context: { ...initialContext },
      stepResults: {},
      startedAt: new Date().toISOString(),
    };

    let currentStep = workflow.steps[0];

    while (currentStep) {
      execution.currentStepId = currentStep.id;

      const result = await this.executeStep(currentStep, execution.context);
      execution.stepResults[currentStep.id] = result;

      if (result.status === "failed") {
        // Try failure path
        if (currentStep.onFailure) {
          currentStep = workflow.steps.find((s) => s.id === currentStep!.onFailure) ?? undefined as any;
          continue;
        }
        execution.status = "failed";
        break;
      }

      // Merge output into context
      if (result.output && typeof result.output === "object") {
        Object.assign(execution.context, result.output);
      }

      // Determine next step
      if (currentStep.type === "condition") {
        const conditionResult = (result.output as { result: boolean })?.result;
        const nextId = conditionResult
          ? currentStep.onSuccess
          : currentStep.onFailure;
        currentStep = nextId
          ? (workflow.steps.find((s) => s.id === nextId) ?? undefined as any)
          : undefined as any;
      } else if (currentStep.type === "approval" && (result.output as { status: string })?.status === "pending") {
        // Pause execution for approval
        execution.status = "paused";
        break;
      } else {
        currentStep = currentStep.nextStepId
          ? (workflow.steps.find((s) => s.id === currentStep!.nextStepId) ?? undefined as any)
          : undefined as any;
      }
    }

    if (execution.status === "active") {
      execution.status = "completed";
      execution.completedAt = new Date().toISOString();
    }

    return execution;
  }

  private async executeStep(
    step: WorkflowStep,
    context: Record<string, unknown>
  ): Promise<StepResult> {
    const handler = stepHandlers[step.type];
    if (!handler) {
      return {
        status: "failed",
        error: `No handler for step type: ${step.type}`,
        startedAt: new Date().toISOString(),
      };
    }

    const startedAt = new Date().toISOString();

    try {
      const result = await handler(step, context);
      return {
        status: result.success ? "completed" : "failed",
        output: result.output,
        error: result.error,
        startedAt,
        completedAt: new Date().toISOString(),
      };
    } catch (err) {
      return {
        status: "failed",
        error: err instanceof Error ? err.message : "Unknown error",
        startedAt,
        completedAt: new Date().toISOString(),
      };
    }
  }
}

// Placeholder functions
const actions: Record<string, (config: Record<string, unknown>, context: Record<string, unknown>) => Promise<unknown>> = {};
function resolveValue(template: string, context: Record<string, unknown>) {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(context[key] ?? ""));
}
async function sendNotification(_params: { to: string; template: string; data: unknown }) {}

API Routes

// app/api/workflows/[id]/execute/route.ts
import { NextRequest, NextResponse } from "next/server";
import { WorkflowEngine } from "@/lib/workflow/engine";
import { db } from "@/db";

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();

  const workflow = await db.query.workflows.findFirst({
    where: (w, { eq }) => eq(w.id, id),
  });

  if (!workflow) {
    return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
  }

  const engine = new WorkflowEngine();
  const execution = await engine.execute(workflow, body.context ?? {});

  // Save execution
  await db.insert(workflowExecutions).values(execution);

  return NextResponse.json(execution);
}

Execution Timeline UI

"use client";

import type { WorkflowExecution, WorkflowStep, StepStatus } from "@/types/workflow";

interface ExecutionTimelineProps {
  execution: WorkflowExecution;
  steps: WorkflowStep[];
}

export function ExecutionTimeline({ execution, steps }: ExecutionTimelineProps) {
  return (
    <div className="space-y-0">
      {steps.map((step, i) => {
        const result = execution.stepResults[step.id];
        const status: StepStatus = result?.status ?? "pending";
        const isCurrent = execution.currentStepId === step.id;

        return (
          <div key={step.id} className="flex gap-3">
            {/* Timeline connector */}
            <div className="flex flex-col items-center">
              <StepIcon status={status} isCurrent={isCurrent} />
              {i < steps.length - 1 && (
                <div className={`w-0.5 h-8 ${status === "completed" ? "bg-green-500" : "bg-border"}`} />
              )}
            </div>

            {/* Step content */}
            <div className="pb-6">
              <div className="flex items-center gap-2">
                <span className="text-sm font-medium">{step.name}</span>
                <span className={`text-xs px-1.5 py-0.5 rounded-full ${statusStyles[status]}`}>
                  {status}
                </span>
              </div>
              <span className="text-xs text-muted-foreground capitalize">{step.type}</span>
              {result?.error && (
                <p className="text-xs text-red-600 mt-1">{result.error}</p>
              )}
              {result?.completedAt && (
                <p className="text-xs text-muted-foreground mt-0.5">
                  {new Date(result.completedAt).toLocaleTimeString()}
                </p>
              )}
            </div>
          </div>
        );
      })}
    </div>
  );
}

const statusStyles: Record<StepStatus, string> = {
  pending: "bg-gray-100 text-gray-600",
  "in-progress": "bg-blue-100 text-blue-700",
  completed: "bg-green-100 text-green-700",
  skipped: "bg-gray-100 text-gray-500",
  failed: "bg-red-100 text-red-700",
};

function StepIcon({ status, isCurrent }: { status: StepStatus; isCurrent: boolean }) {
  const base = "w-6 h-6 rounded-full flex items-center justify-center text-xs shrink-0";

  if (isCurrent && status !== "completed") {
    return <div className={`${base} bg-blue-500 text-white animate-pulse`}>...</div>;
  }

  switch (status) {
    case "completed":
      return (
        <div className={`${base} bg-green-500 text-white`}>
          <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
          </svg>
        </div>
      );
    case "failed":
      return <div className={`${base} bg-red-500 text-white`}>x</div>;
    default:
      return <div className={`${base} border-2 border-border`} />;
  }
}

Need Business Process Automation?

We design and build workflow automation systems for businesses. Contact us to streamline your operations.

workflowautomationstate machineNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles