name: workflow description: > Vercel Workflow SDK for building durable, resumable workflows in Next.js. Use when creating workflow files with 'use workflow' / 'use step' directives, starting workflows with start(), checking run status with getRun(), configuring withWorkflow() in next.config.ts, implementing durable steps, sleep(), hooks, webhooks, error handling with FatalError/RetryableError, streaming with getWritable(), or idempotency with getStepMetadata(). Triggers on: workflow, durable, 'use step', 'use workflow', withWorkflow, sleep, resumable, step function, workflow/api, workflow/next.
Vercel Workflow SDK
Durable, resumable workflows for Next.js. Workflows survive server restarts, deploy between steps, and consume zero compute while waiting.
Quick Reference
Setup
Install and configure:
pnpm add workflow
Wrap next.config.ts with withWorkflow():
import { withWorkflow } from 'workflow/next'
const nextConfig = {}
export default withWorkflow(nextConfig)
When composing with other plugins, call each in sequence:
import { withWorkflow } from 'workflow/next'
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
export default withWorkflow(withNextIntl(nextConfig))
Imports Map
From 'workflow':
sleep-- Durable pause (sleep('10s'),sleep('7 days'))FatalError-- Non-retriable error (skips retry)RetryableError-- Explicitly retriable error with optional delaygetStepMetadata-- Returns{ stepId }for idempotency keysgetWorkflowMetadata-- Returns{ runId, workflowName }at runtimegetWritable-- Access the run's default WritableStream for streamingdefineHook-- Type-safe hook for external eventscreateHook-- Low-level hook for arbitrary payloadscreateWebhook-- HTTP webhook that suspends workflow until calledfetch-- HTTP fetch with automatic retry semantics
From 'workflow/api':
start-- Start/enqueue a workflow rungetRun-- Get run status/result by ID (synchronous, returns Run object)resumeHook-- Resume a suspended hook with payloadresumeWebhook-- Resume a webhook with a Request objectgetHookByToken-- Get hook details by tokengetWorld-- Direct access to storage/queuing internals
From 'workflow/next':
withWorkflow-- Next.js config wrapper (generates routes)
File Organization
workflows/ -- Workflow files with 'use workflow'
app/api/ -- API routes that call start() or getRun()
next.config.ts -- withWorkflow() wrapper
app/.well-known/ -- Auto-generated by withWorkflow (do NOT edit)
Implementation Guide
Core Directives
'use workflow' -- File-level directive marking a durable workflow function.
export async function myWorkflow(input: string) {
'use workflow'
const a = await stepA(input)
const b = await stepB(a)
return b
}
'use step' -- Function-level directive marking a durable step.
async function stepA(data: string) {
'use step'
// This compiles into an isolated API route.
// Each step is a durability boundary.
return await processData(data)
}
Rules:
- Both directives ONLY work with
async functiondeclarations - Arrow functions do NOT work:
const step = async () => { 'use step' }will silently fail - Step functions must be at root level, NOT nested inside other functions
- Steps compile into isolated API routes -- they cannot access module-scope imports from the workflow file
- Use
await import()for dependencies needed inside step functions
Starting and Monitoring Workflows
Start a workflow from an API route or server action:
import { start } from 'workflow/api'
import { myWorkflow } from '@/workflows/my-workflow'
const run = await start(myWorkflow, ['argument1', 'argument2'])
// run.runId -- unique identifier
// run.status -- Promise<WorkflowRunStatus>
// run.returnValue -- Promise<T>
// run.cancel() -- cancel the run
Check status (note: getRun() is synchronous, returns Run object directly):
import { getRun } from 'workflow/api'
const run = getRun(runId) // NOT async -- returns Run directly
const status = await run.status
// 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
if (status === 'completed') {
const result = await run.returnValue
}
Run Object Shape
interface Run<T> {
runId: string
status: Promise<WorkflowRunStatus>
returnValue: Promise<T>
workflowName: Promise<string>
createdAt: Promise<Date>
startedAt: Promise<Date | undefined>
completedAt: Promise<Date | undefined>
readable: ReadableStream
cancel(): Promise<void>
getReadable(startIndex?: number): ReadableStream
}
Sequential Steps
export async function pipeline(input: string) {
'use workflow'
const validated = await validate(input)
const processed = await process(validated)
const result = await save(processed)
return result
}
Parallel Steps
export async function parallelWorkflow(data: Data) {
'use workflow'
const [images, text] = await Promise.all([
processImages(data.images),
processText(data.text),
])
return await combine(images, text)
}
Durable Sleep
sleep() is durable -- zero compute consumed while waiting. The workflow resumes automatically.
import { sleep } from 'workflow'
export async function reminderWorkflow(userId: string) {
'use workflow'
await sendWelcomeEmail(userId)
await sleep('7 days')
await sendFollowUpEmail(userId)
await sleep('30 days')
await sendRetentionEmail(userId)
}
Accepted formats: '10s', '5m', '2h', '7 days'
Error Handling
import { FatalError, RetryableError } from 'workflow'
async function processPayment(orderId: string) {
'use step'
// Validation failure -- don't retry
if (!orderId) throw new FatalError('Missing order ID')
// Rate limited -- retry with specific delay
if (rateLimited) throw new RetryableError('Rate limited', { retryAfter: 30 })
// Regular errors retry automatically with exponential backoff
const res = await fetch(url)
if (!res.ok) throw new Error('Payment API failed') // will retry
}
Default retry behavior: 64 max deliveries, 5s initial retry delay, exponential backoff. Regular throw new Error() triggers automatic retries. FatalError skips retries entirely.
Idempotency
Use getStepMetadata() to get a stable stepId for idempotency keys:
import { getStepMetadata } from 'workflow'
async function chargeUser(userId: string, amount: number) {
'use step'
const { stepId } = getStepMetadata()
await stripe.charges.create(
{ amount, customer: userId },
{ idempotencyKey: stepId }
)
}
Streaming
Write partial results from steps using getWritable():
import { getWritable } from 'workflow'
async function generateReport() {
'use step'
const writable = getWritable()
const writer = writable.getWriter()
await writer.write('Processing section 1...\n')
// ... do work ...
await writer.write('Processing section 2...\n')
await writer.close()
}
Read from a run:
const run = await start(myWorkflow, [args])
const reader = run.readable.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
console.log(new TextDecoder().decode(value))
}
Resume reading from a specific position with run.getReadable(startIndex).
Advanced Patterns
Human-in-the-Loop (Hooks)
Define a typed hook that suspends the workflow until resumed:
import { defineHook } from 'workflow'
const approvalHook = defineHook<{ approved: boolean }>()
export async function approvalWorkflow(content: string) {
'use workflow'
const draft = await generateDraft(content)
const events = approvalHook.create({ token: draft.id })
for await (const event of events) {
if (event.approved) return await publish(draft)
else return await discard(draft)
}
}
Resume from an API route:
import { resumeHook } from 'workflow/api'
export async function POST(req: Request) {
const { token, approved } = await req.json()
await resumeHook(token, { approved })
return Response.json({ ok: true })
}
Webhooks
Create an HTTP webhook endpoint that suspends the workflow:
import { createWebhook } from 'workflow'
async function waitForPayment(orderId: string) {
'use step'
const webhook = createWebhook()
await saveWebhookUrl(orderId, webhook.url) // give URL to payment provider
const payload = await webhook // suspends until called
return payload
}
Resume from an API route:
import { resumeWebhook } from 'workflow/api'
export async function POST(req: Request) {
await resumeWebhook(token, req)
return Response.json({ ok: true })
}
Workflow Metadata
Access run-level metadata from within a workflow or step:
import { getWorkflowMetadata } from 'workflow'
async function logStep() {
'use step'
const { runId, workflowName } = getWorkflowMetadata()
console.log(`Running ${workflowName} [${runId}]`)
}
Durable Fetch
workflow exports its own fetch with automatic retry semantics:
import { fetch } from 'workflow'
async function callExternalAPI() {
'use step'
const res = await fetch('https://api.example.com/data')
return await res.json()
}
Critical Gotchas
Arrow functions silently fail --
'use step'and'use workflow'ONLY work withasync functiondeclarations. Arrow functions compile without error but steps will not be durable.Steps are isolated -- Each step compiles into its own API route. Module-scope imports from the workflow file are NOT available inside steps. Use
await import()for dependencies.Steps are stateless -- Do not rely on module-scope variables or closures between steps. Each step runs in a fresh, isolated context. Pass data through return values.
getRun()is synchronous -- It returns aRunobject directly (not a Promise). The Run's properties (status,returnValue,createdAt) are Promises you must await individually.sleep()format -- Accepts human-readable strings:'10s','5m','2h','7 days'. Not milliseconds.Generated routes --
withWorkflow()auto-generatesapp/.well-known/workflow/v1/containingflow/,step/, andwebhook/[token]/routes. Do not edit these.Retry defaults -- 64 max deliveries, 5s initial delay, exponential backoff.
FatalErrorbypasses retries.RetryableErrorallows explicitretryAfterin seconds.serverExternalPackages-- If a dependency used inside a step has bundling issues, add it tonextConfig.serverExternalPackagesarray.Monorepo "pending" state -- Workflows stuck in "pending" in turborepo/monorepo setups usually means
withWorkflow()is misconfigured. Check build logs and verify the config wrapper is applied.Dynamic imports in steps -- Since steps are isolated routes, use
const { thing } = await import('package')inside step bodies for any non-trivial dependencies.