# How to Build Workflow automation with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium (Supabase + Vercel Cron)
- Last updated: April 2026

## TL;DR

Build a Zapier-lite workflow automation platform with V0 featuring a visual trigger-action builder using reactflow, webhook and cron triggers, sequential step execution with context passing, and detailed run logs. You'll configure Vercel Cron Jobs in vercel.json, build a reduce-style async pipeline, and log per-step results — all in about 2-4 hours.

## Before you start

- A V0 account (Premium recommended for the project complexity)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Resend account for the send-email action (free tier: 100 emails/day)
- No additional services needed — Vercel Cron Jobs are built into the platform

## Step-by-step guide

### 1. Set up the workflows, steps, runs, and logs schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the tables for workflow definitions, configurable steps, execution runs, and per-step logs.

```
CREATE TABLE workflows (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id uuid NOT NULL,
  name text NOT NULL,
  description text,
  is_active boolean DEFAULT false,
  trigger_type text NOT NULL
    CHECK (trigger_type IN ('webhook','schedule','db_change')),
  trigger_config jsonb DEFAULT '{}',
  created_at timestamptz DEFAULT now(),
  updated_at timestamptz DEFAULT now()
);

CREATE TABLE workflow_steps (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workflow_id uuid REFERENCES workflows(id) ON DELETE CASCADE,
  position int NOT NULL,
  action_type text NOT NULL
    CHECK (action_type IN ('http_request','send_email','transform_data','condition','delay','supabase_query')),
  config jsonb NOT NULL DEFAULT '{}',
  created_at timestamptz DEFAULT now()
);

CREATE TABLE workflow_runs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workflow_id uuid REFERENCES workflows(id) ON DELETE CASCADE,
  status text DEFAULT 'running'
    CHECK (status IN ('running','completed','failed')),
  trigger_data jsonb,
  started_at timestamptz DEFAULT now(),
  completed_at timestamptz
);

CREATE TABLE step_logs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id uuid REFERENCES workflow_runs(id) ON DELETE CASCADE,
  step_id uuid REFERENCES workflow_steps(id) ON DELETE CASCADE,
  status text CHECK (status IN ('success','failed','skipped')),
  input jsonb,
  output jsonb,
  error_message text,
  duration_ms int,
  executed_at timestamptz DEFAULT now()
);

CREATE INDEX idx_steps_workflow ON workflow_steps(workflow_id, position);
CREATE INDEX idx_runs_workflow ON workflow_runs(workflow_id, started_at DESC);
```

> Pro tip: The position column on workflow_steps determines execution order. Using integers with gaps (10, 20, 30) makes it easier to insert steps between existing ones without reordering.

**Expected result:** Four tables created with indexes for fast step ordering and run history queries. Step logs track input, output, and errors for each step in each run.

### 2. Build the workflow execution pipeline

Create the core execution engine that fetches a workflow's steps in order, runs them sequentially, and passes a context object from step to step. Each step's output is merged into the context for the next step.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { Resend } from 'resend'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const resend = new Resend(process.env.RESEND_API_KEY)

async function executeStep(
  step: { id: string; action_type: string; config: Record<string, unknown> },
  context: Record<string, unknown>
): Promise<Record<string, unknown>> {
  switch (step.action_type) {
    case 'http_request': {
      const { url, method, headers, body } = step.config as {
        url: string; method: string; headers?: Record<string, string>; body?: string
      }
      const res = await fetch(url, { method, headers, body })
      return { status: res.status, data: await res.json() }
    }
    case 'send_email': {
      const { to, subject, template } = step.config as {
        to: string; subject: string; template: string
      }
      await resend.emails.send({
        from: 'workflows@yourdomain.com',
        to,
        subject,
        html: template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
          String(context[key] ?? '')
        ),
      })
      return { sent: true, to }
    }
    case 'transform_data': {
      const { expression } = step.config as { expression: string }
      const fn = new Function('ctx', `return (${expression})`)
      return { result: fn(context) }
    }
    case 'delay': {
      const { ms } = step.config as { ms: number }
      await new Promise((r) => setTimeout(r, Math.min(ms, 10000)))
      return { delayed: ms }
    }
    case 'supabase_query': {
      const { table, operation, filters, data } = step.config as {
        table: string; operation: string; filters?: Record<string, unknown>; data?: Record<string, unknown>
      }
      let query = supabase.from(table)
      if (operation === 'select') {
        const { data: rows } = await query.select('*').match(filters ?? {})
        return { rows }
      }
      if (operation === 'insert') {
        await query.insert(data ?? {})
        return { inserted: true }
      }
      return {}
    }
    default:
      return {}
  }
}

export async function POST(req: NextRequest) {
  const { workflowId, triggerData } = await req.json()

  const { data: run } = await supabase
    .from('workflow_runs')
    .insert({ workflow_id: workflowId, trigger_data: triggerData })
    .select()
    .single()

  const { data: steps } = await supabase
    .from('workflow_steps')
    .select('*')
    .eq('workflow_id', workflowId)
    .order('position')

  let context: Record<string, unknown> = { trigger: triggerData }
  let failed = false

  for (const step of steps ?? []) {
    const start = Date.now()
    try {
      const output = await executeStep(step, context)
      context = { ...context, [step.action_type]: output }

      await supabase.from('step_logs').insert({
        run_id: run!.id,
        step_id: step.id,
        status: 'success',
        input: context,
        output,
        duration_ms: Date.now() - start,
      })
    } catch (err) {
      failed = true
      await supabase.from('step_logs').insert({
        run_id: run!.id,
        step_id: step.id,
        status: 'failed',
        input: context,
        error_message: err instanceof Error ? err.message : 'Unknown error',
        duration_ms: Date.now() - start,
      })
    }
  }

  await supabase
    .from('workflow_runs')
    .update({
      status: failed ? 'failed' : 'completed',
      completed_at: new Date().toISOString(),
    })
    .eq('id', run!.id)

  return NextResponse.json({ runId: run!.id, status: failed ? 'failed' : 'completed' })
}
```

> Pro tip: The context object accumulates through the pipeline. Each step can read previous steps' output via context.http_request.data or context.trigger.fieldName. Failed steps log their error but do not halt subsequent steps.

**Expected result:** The execute endpoint runs all steps sequentially, logs each step's input/output/duration, and marks the run as completed or failed.

### 3. Create webhook and cron trigger endpoints

Build the webhook trigger that accepts POST data and the cron trigger that checks for scheduled workflows. Configure Vercel Cron Jobs in vercel.json.

```
// app/api/webhooks/trigger/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

  const { data: workflow } = await supabase
    .from('workflows')
    .select('id, is_active, trigger_type')
    .eq('id', id)
    .eq('trigger_type', 'webhook')
    .eq('is_active', true)
    .single()

  if (!workflow) {
    return NextResponse.json({ error: 'Workflow not found or inactive' }, { status: 404 })
  }

  const executeRes = await fetch(
    `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/execute`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ workflowId: id, triggerData }),
    }
  )

  const result = await executeRes.json()
  return NextResponse.json(result)
}

// vercel.json — add this to the project root
// { "crons": [{ "path": "/api/cron", "schedule": "*/5 * * * *" }] }
```

> Pro tip: Vercel Cron Jobs are configured in vercel.json and run automatically in production. The cron route should query for active workflows with trigger_type='schedule' and matching cron expressions, then call the execute endpoint for each.

**Expected result:** Webhook triggers accept POST data and execute the workflow. Cron triggers run every 5 minutes, checking for due scheduled workflows.

### 4. Build the visual workflow builder with reactflow

Create the workflow editor page using reactflow for visual node editing. Users drag trigger and action nodes into a canvas, configure each step, and connect them to define the execution flow.

```
// Paste this prompt into V0's AI chat:
// Build a visual workflow builder at app/workflows/[id]/page.tsx with:
// 1. Install reactflow and add it as a client component
// 2. Custom node types: TriggerNode (webhook/schedule/db_change with config), ActionNode (http_request/send_email/transform_data/condition/delay/supabase_query)
// 3. Sidebar with draggable action types that can be dropped onto the canvas
// 4. Clicking a node opens a Dialog with a dynamic Form based on action_type:
//    - http_request: Input for URL, Select for method, Textarea for headers JSON, Textarea for body
//    - send_email: Input for to/subject, Textarea for HTML template with {{variable}} placeholders
//    - transform_data: Textarea for JavaScript expression
//    - condition: Input for field, Select for operator, Input for value
//    - delay: Input for milliseconds
//    - supabase_query: Input for table, Select for operation, Textarea for filters/data JSON
// 5. Save Button that persists the node positions and step configs to Supabase
// 6. shadcn/ui Switch for workflow active toggle
// 7. Show the webhook URL (for webhook triggers) with copy-to-clipboard Button
// Use reactflow's built-in edges for connecting nodes.
```

**Expected result:** A visual canvas where users drag action nodes, connect them to a trigger, configure each step via Dialog forms, and save the workflow to Supabase.

### 5. Build the run history and step logs page

Create a page showing all runs for a workflow with expandable step-by-step logs showing input, output, duration, and error messages.

```
// Paste this prompt into V0's AI chat:
// Build a workflow run history page at app/workflows/[id]/runs/page.tsx with:
// 1. Server Component fetching all workflow_runs for the workflow, ordered by started_at DESC
// 2. shadcn/ui Table with columns: Run ID (truncated), Status Badge (running=secondary, completed=default, failed=destructive), Started At, Duration, Steps Passed/Total
// 3. Clicking a row expands an Accordion showing step_logs for that run
// 4. Each step log shows: step action_type Badge, status Badge, duration, input (collapsible JSON), output (collapsible JSON), error message if failed
// 5. Summary Cards at top: total runs, success rate percentage, average duration, runs today
// 6. Manual trigger Button that executes the workflow with an optional JSON input Dialog
// 7. Filter by status using Select dropdown
// Use pre/code blocks for JSON display with syntax highlighting.
```

**Expected result:** A run history page with Table of runs, expandable Accordion for step logs, status Badges, and summary Cards showing execution statistics.

## Complete code example

File: `app/api/workflows/execute/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { workflowId, triggerData } = await req.json()

  const { data: run } = await supabase
    .from('workflow_runs')
    .insert({ workflow_id: workflowId, trigger_data: triggerData })
    .select()
    .single()

  const { data: steps } = await supabase
    .from('workflow_steps')
    .select('*')
    .eq('workflow_id', workflowId)
    .order('position')

  let context: Record<string, unknown> = { trigger: triggerData }
  let failed = false

  for (const step of steps ?? []) {
    const start = Date.now()
    try {
      const output = await executeStep(step, context)
      context = { ...context, [`step_${step.position}`]: output }

      await supabase.from('step_logs').insert({
        run_id: run!.id,
        step_id: step.id,
        status: 'success',
        input: context,
        output,
        duration_ms: Date.now() - start,
      })
    } catch (err) {
      failed = true
      await supabase.from('step_logs').insert({
        run_id: run!.id,
        step_id: step.id,
        status: 'failed',
        input: context,
        error_message:
          err instanceof Error ? err.message : 'Unknown error',
        duration_ms: Date.now() - start,
      })
    }
  }

  await supabase
    .from('workflow_runs')
    .update({
      status: failed ? 'failed' : 'completed',
      completed_at: new Date().toISOString(),
    })
    .eq('id', run!.id)

  return NextResponse.json({
    runId: run!.id,
    status: failed ? 'failed' : 'completed',
  })
}
```

## Common mistakes

- **Running workflow steps in parallel instead of sequentially** — Workflow steps depend on previous steps' output. Running them in parallel means step 3 cannot access step 2's result because it has not completed yet. Fix: Use a sequential for...of loop (not Promise.all) to execute steps one at a time. Each step's output is merged into the context object before the next step runs.
- **Not logging step inputs and outputs** — Without per-step logs, debugging a failed workflow requires guessing which step failed and what data it received. Users cannot diagnose issues. Fix: Log the full context (input), step output, duration, and any error message to the step_logs table for every step execution, whether it succeeds or fails.
- **Using Vercel Cron with test URLs instead of production** — Vercel Cron Jobs only run on the production deployment. They do not fire on preview deployments or localhost, so scheduled workflows will never trigger during development. Fix: Test scheduled workflows manually by calling the cron endpoint directly during development. Vercel Cron Jobs activate automatically on the production deployment.
- **Not capping the delay action duration** — A user could configure a delay of 300,000ms (5 minutes), exceeding Vercel's serverless timeout and causing the entire workflow to fail. Fix: Cap the delay action at 10 seconds (Math.min(ms, 10000)). For longer delays, split the workflow into two workflows where the first triggers the second after a schedule.

## Best practices

- Execute workflow steps sequentially using a for...of loop with a shared context object that accumulates each step's output
- Log every step execution to step_logs with input, output, duration, and error details for debugging
- Configure Vercel Cron Jobs in vercel.json for scheduled triggers — this is a native Vercel capability that requires no third-party service
- Cap delay actions at 10 seconds to stay within Vercel serverless timeout limits
- Use V0's prompt queuing to build the workflow list, visual builder, and run history as three queued prompts
- Set RESEND_API_KEY in V0's Vars tab (server-only, no NEXT_PUBLIC_ prefix) for the send-email action
- Generate unique webhook URLs per workflow using the deployed Vercel domain so each workflow has its own trigger endpoint

## Frequently asked questions

### How does context passing work between steps?

The execution engine maintains a context object initialized with { trigger: triggerData }. After each step executes, its output is merged into the context (e.g., context.step_10 = { status: 200, data: {...} }). The next step receives the full accumulated context, so it can reference any previous step's output.

### How do Vercel Cron Jobs work?

Add a crons array to vercel.json: { "crons": [{ "path": "/api/cron", "schedule": "*/5 * * * *" }] }. Vercel calls the specified path on the production deployment at the schedule interval. The cron route queries for active scheduled workflows and triggers their execution.

### Can I test cron workflows locally?

Vercel Cron Jobs only run in production. For local testing, call the /api/cron endpoint directly via curl or the browser. You can also add a manual trigger Button on the workflow page that calls the execute endpoint with test data.

### What V0 plan do I need?

V0 Premium ($20/month) is recommended. The workflow builder involves reactflow integration, multiple API routes, and a complex dashboard that requires several prompt iterations. Vercel Cron Jobs work on all Vercel plans including Hobby.

### What happens when a step fails?

The failed step is logged with its error message and the context at the time of failure. Subsequent steps continue executing (the pipeline does not halt). The overall run is marked as 'failed' if any step fails. This allows partial workflow completion while making failures visible in the logs.

### How do I deploy this?

Click Share then Publish in V0. Set RESEND_API_KEY in V0's Vars tab (no NEXT_PUBLIC_ prefix). Ensure vercel.json contains the crons configuration. After deployment, webhook trigger URLs are available at https://your-domain.vercel.app/api/webhooks/trigger/{workflow-id}.

### Can RapidDev help build a custom automation platform?

Yes. RapidDev has built 600+ apps including workflow automation platforms with complex trigger systems, multi-step pipelines, and integration layers. Book a free consultation to discuss your automation requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/workflow-automation
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/workflow-automation
