skills /vercel-workflow
Next.js Referenced

vercel-workflow

Build durable, resilient, and observable TypeScript workflows with Vercel Workflow DevKit. Use when creating workflows that need durability (pause/resume across deploys and crashes), reliability (automatic retries), long-running processes (minutes to months), AI agents, event-driven systems, webhooks, or multi-step async operations in Next.js/TypeScript applications.

Vercel Workflow DevKit

Build durable, resilient, and observable TypeScript workflows that can suspend, resume, and maintain state across deployments, crashes, and long time periods.

Overview

Vercel Workflow DevKit (WDK) transforms ordinary async TypeScript functions into durable workflows using two simple directives:

  • "use workflow" - Marks deterministic orchestrator functions
  • "use step" - Marks side-effecting operations with automatic retries

Key capabilities:

  • Durability: Workflows survive deployments and crashes, resuming exactly where they stopped
  • Reliability: Automatic retry logic with configurable error handling
  • Long-running: Pause for seconds, hours, days, or months without consuming resources
  • Observability: Built-in traces, logs, and metrics for every run
  • Portable: Runs locally, on Vercel, or any cloud via custom "Worlds"

When to Use This Skill

Use Vercel Workflow when building:

  • AI agents: Multi-step reasoning with pauses between API calls
  • User onboarding: Email sequences with delays (e.g., welcome, 1-week check-in, 1-month survey)
  • E-commerce: Order processing with payment confirmations and inventory checks
  • Approval flows: Pause for human approval before continuing
  • Data pipelines: RAG ingestion, embedding, and indexing that can fail/resume
  • Event-driven systems: React to webhooks and external events over time
  • Scheduled tasks: Cron-like workflows with durability guarantees

Core Concepts

Workflows vs Steps

Workflows ("use workflow"):

  • Deterministic orchestrators that coordinate steps
  • Must be pure functions during replay (same inputs → same outputs)
  • Can call steps, use control flow, and handle serializable data
  • Cannot make direct API calls, database queries, or have side effects

Steps ("use step"):

  • Side-effecting operations with full Node.js runtime access
  • Can make API calls, database queries, file I/O, etc.
  • Automatically retried on failure (unless FatalError is thrown)
  • Results are persisted and cached for workflow replay

Critical distinction: During workflow resumption, the workflow replays from the beginning using cached step results. This requires workflows to be deterministic (no Math.random(), Date.now(), etc. unless in steps).

Quick Start

1. Installation (Next.js)

hljs bash
npm install workflow

2. Configure Next.js

Wrap next.config.ts with withWorkflow():

hljs typescript
import { withWorkflow } from 'workflow/next'; import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // Your Next.js config }; export default withWorkflow(nextConfig);

3. Create Your First Workflow

Create a workflow file (e.g., workflows/user-signup.ts):

hljs typescript
import { sleep, FatalError } from "workflow"; export async function handleUserSignup(email: string) { "use workflow"; // Step 1: Create user const user = await createUser(email); // Step 2: Send welcome email await sendWelcomeEmail(user); // Step 3: Wait 7 days await sleep(7 * 24 * 60 * 60 * 1000); // Step 4: Send follow-up await sendFollowUpEmail(user); return { success: true, userId: user.id }; } async function createUser(email: string) { "use step"; console.log(`Creating user: ${email}`); return { id: crypto.randomUUID(), email }; } async function sendWelcomeEmail(user: { id: string; email: string }) { "use step"; if (!user.email.includes("@")) { // FatalError = don't retry throw new FatalError("Invalid email format"); } console.log(`Sending welcome email to: ${user.email}`); // Regular errors are automatically retried } async function sendFollowUpEmail(user: { id: string; email: string }) { "use step"; console.log(`Sending follow-up to: ${user.email}`); }

4. Trigger the Workflow

From an API route (app/api/signup/route.ts):

hljs typescript
import { start } from 'workflow/api'; import { handleUserSignup } from '@/workflows/user-signup'; import { NextResponse } from 'next/server'; export async function POST(request: Request) { const { email } = await request.json(); // Start workflow asynchronously (doesn't block) await start(handleUserSignup, [email]); return NextResponse.json({ message: "User signup workflow started" }); }

Essential Patterns

Sleeping (Time-Based Pausing)

Use sleep() to pause workflows without consuming resources:

hljs typescript
import { sleep } from "workflow"; export async function subscriptionRenewal(userId: string) { "use workflow"; await notifyUpcomingRenewal(userId); // Sleep for 30 days await sleep(30 * 24 * 60 * 60 * 1000); await processRenewal(userId); }

Error Handling

Retryable errors (default):

hljs typescript
async function callExternalAPI() { "use step"; const response = await fetch("https://api.example.com/data"); if (!response.ok) { // Will be automatically retried throw new Error("API call failed"); } return response.json(); }

Non-retryable errors (use FatalError):

hljs typescript
import { FatalError } from "workflow"; async function processPayment(amount: number) { "use step"; if (amount <= 0) { // Don't retry invalid input throw new FatalError("Invalid payment amount"); } // Process payment... }

Hooks (Custom Pause/Resume)

Pause workflows and resume with arbitrary data:

hljs typescript
import { createHook } from "workflow"; export async function approvalWorkflow(documentId: string) { "use workflow"; const hook = createHook<{ approved: boolean; comment: string }>({ token: `approval:${documentId}` // Custom deterministic token }); await notifyApprover(documentId, hook.token); // Workflow pauses here const decision = await hook; if (decision.approved) { await publishDocument(documentId); } else { await archiveDocument(documentId); } }

Resume from external code:

hljs typescript
import { resumeHook } from "workflow/api"; // In API route export async function POST(request: Request) { const { documentId, approved, comment } = await request.json(); await resumeHook(`approval:${documentId}`, { approved, comment }); return Response.json({ success: true }); }

Webhooks (HTTP-Based Pausing)

Create HTTP endpoints that automatically resume workflows:

hljs typescript
import { createWebhook } from "workflow"; export async function paymentWorkflow(orderId: string) { "use workflow"; const webhook = createWebhook({ token: `payment:${orderId}`, respondWith: new Response(JSON.stringify({ received: true }), { headers: { "Content-Type": "application/json" } }) }); // Send webhook URL to payment provider await initiatePayment(orderId, webhook.url); // Workflow pauses until webhook is called const request = await webhook; const paymentData = await request.json(); await fulfillOrder(orderId, paymentData); }

Webhooks are automatically available at: /.well-known/workflow/v1/webhook/:token

Receiving Multiple Events

Use for await...of to handle multiple events:

hljs typescript
import { createHook } from "workflow"; export async function chatbotWorkflow(channelId: string) { "use workflow"; const hook = createHook<{ message: string; user: string }>({ token: `chat:${channelId}` }); for await (const event of hook) { if (event.message === "/stop") { break; } await processMessage(event); } }

Advanced Patterns

Type-Safe Hooks

Use defineHook() for type safety across files:

hljs typescript
import { defineHook } from "workflow"; type ApprovalPayload = { requestId: string; approved: boolean; approvedBy: string; comment: string; }; export const approvalHook = defineHook<ApprovalPayload>(); // In workflow file export async function requestApproval(docId: string) { "use workflow"; const hook = approvalHook.create({ token: `approval:${docId}` }); const result = await hook; // TypeScript knows the exact shape of result } // In API route - TypeScript ensures correct payload shape await approvalHook.resume(`approval:${docId}`, { requestId: docId, approved: true, approvedBy: "user@example.com", comment: "Approved" });

Dynamic Webhook Responses

Respond based on request content:

hljs typescript
import { createWebhook, type RequestWithResponse } from "workflow"; async function sendResponse(request: RequestWithResponse, message: string) { "use step"; await request.respondWith( new Response(JSON.stringify({ message }), { status: 200, headers: { "Content-Type": "application/json" } }) ); } export async function smartWebhookWorkflow() { "use workflow"; const webhook = createWebhook({ respondWith: "manual" }); const request = await webhook; const data = await request.json(); if (data.priority === "urgent") { await sendResponse(request, "Processing urgently"); await processUrgently(data); } else { await sendResponse(request, "Processing normally"); await processNormally(data); } }

Control Flow

Standard JavaScript control flow works in workflows:

hljs typescript
export async function orderWorkflow(orderId: string) { "use workflow"; const order = await fetchOrder(orderId); // Conditional logic if (order.amount > 1000) { await requestManagerApproval(orderId); } // Loops for (const item of order.items) { await checkInventory(item); } // Parallel execution with Promise.all const [payment, inventory] = await Promise.all([ processPayment(orderId), reserveInventory(orderId) ]); return { payment, inventory }; }

Best Practices

1. Keep Workflows Deterministic

DO:

hljs typescript
export async function goodWorkflow(userId: string) { "use workflow"; // Get time from a step const timestamp = await getCurrentTime(); // Use workflow input const user = await getUser(userId); return { user, timestamp }; } async function getCurrentTime() { "use step"; return Date.now(); // Side effects belong in steps }

DON'T:

hljs typescript
export async function badWorkflow(userId: string) { "use workflow"; // ❌ Non-deterministic - will break on replay! const timestamp = Date.now(); const random = Math.random(); return { timestamp, random }; }

2. Use FatalError for Invalid Input

Don't waste retries on errors that won't be fixed by retrying:

hljs typescript
async function processData(data: string) { "use step"; if (!data || data.trim() === "") { // No point retrying empty data throw new FatalError("Data cannot be empty"); } // Retryable operation return await api.process(data); }

3. Use Custom Tokens for Determinism

Make tokens deterministic so external systems can resume the right workflow:

hljs typescript
const hook = createHook<Payload>({ token: `${resourceType}:${resourceId}:${eventType}` }); // Example: "order:123:payment", "document:456:approval"

4. Serialize Only What You Need

Only pass serializable data between workflows and steps (JSON-compatible):

Good: Primitives, objects, arrays

hljs typescript
const result = await myStep({ id: 123, name: "test" });

Bad: Functions, class instances, symbols, undefined

hljs typescript
// ❌ Won't work const result = await myStep(new MyClass());

Framework Integration

Best support with App Router or Pages Router. See references/next_js_setup.md for detailed setup.

Other Frameworks

  • Nitro: Supported (see WDK docs)
  • SvelteKit: Coming soon
  • Nuxt: Coming soon
  • Hono/Bun: Coming soon

Custom Deployment

Workflows can run on any platform via custom "Worlds". See references/api_reference.md for details.

Observability

Every workflow run includes:

  • Traces: Step-by-step execution path
  • Logs: All console output from workflows and steps
  • Metrics: Duration, retry counts, error rates
  • Time Travel: Replay and debug past executions

Access via CLI or Web UI (automatically included with WDK).

Common Issues

Issue: Workflow doesn't resume after deployment

  • Cause: Non-deterministic code in workflow
  • Fix: Move side effects (API calls, Date.now(), Math.random()) into steps

Issue: Step keeps retrying forever

  • Cause: Regular Error thrown for unrecoverable failure
  • Fix: Use FatalError for invalid input or permanent failures

Issue: Webhook not responding

  • Cause: respondWith: "manual" but respondWith() not called from step
  • Fix: Call request.respondWith() from within a step function

Reference Documentation

For comprehensive API details, see:

External Resources

Related Categories