For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: text/markdown.

Vercel Workflows

Add Vercel Workflows (Workflow SDK) to TurboStarter for durable TypeScript steps that pause, retry, and resume across deploys.

Vercel Workflows run durable TypeScript functions that can pause, retry, and resume after a process crash or redeploy. The open-source Workflow SDK turns ordinary async functions into orchestrators ("use workflow") and retried units of work ("use step").

What Vercel Workflows are good for

Use Workflows when a business process must survive minutes to days (onboarding sequences, approval waits, multi-step billing repairs) while staying close to your Next.js deploy on Vercel. You write async/await; the runtime persists progress between steps.

Public beta

Review current pricing, limits, and release status before putting Workflows on a critical path. Prefer it for genuine durable orchestration, not for a short route handler that finishes in a few seconds.

When to choose Vercel Workflows

NeedPrefer
Durable steps that pause/resume on Vercel with minimal new infraVercel Workflows
Event bus, fan-out, and a mature multi-step dashboardInngest
Dedicated workers and long CPU jobs outside the request pathTrigger.dev
Fire-and-forget HTTP jobs with delay/cronUpstash QStash

Pick Workflows if you deploy primarily to Vercel, want durable orchestration in-repo with "use workflow" / "use step", and accept beta-era limits. Skip it if you need a battle-tested multi-cloud queue today. Trigger.dev or Inngest are safer defaults for production-critical jobs.

Install and configure the Workflow SDK

From the monorepo root (or the web app), run the current setup:

pnpm --filter web exec npx workflow@latest

Or install manually and wrap Next.js config yourself:

pnpm add --filter web workflow

Compose withWorkflow carefully

TurboStarter already wraps next.config.ts (Content Collections, PostHog, and other plugins). Add withWorkflow once around the final config. Do not nest duplicate wrappers if you re-run the CLI.

apps/web/next.config.ts
import { withContentCollections } from "@content-collections/next";
import { withPostHogConfig } from "@posthog/next";
import { withWorkflow } from "workflow/next";

// ... build `config` as today ...

export default withWorkflow(
  withPostHogConfig(withContentCollections(config), {
    // existing PostHog options
  }),
);

Preserve every existing wrapper. Running setup twice should refresh the integration, not stack another withWorkflow.

Optional TypeScript plugin

apps/web/tsconfig.json
{
  "compilerOptions": {
    "plugins": [{ "name": "workflow" }]
  }
}

Pin a reviewed version

Use a current workflow release. Older beta builds had security issues around webhook tokens. After install, run pnpm audit and follow the Workflow SDK security guidance before exposing hooks or webhooks.

Keep Turborepo cache correct

The SDK generates routes under src/app/.well-known/workflow/ during build. If Turborepo caches .next but not those files, cache hits can miss workflow registration.

Update the web app’s build outputs:

apps/web/turbo.json
{
  "tasks": {
    "build": {
      "outputs": [
        ".next/**",
        "!.next/cache/**",
        "src/app/.well-known/workflow/**"
      ]
    }
  }
}

Without this, workflows may work on a cold build and fail intermittently on cache hits.

Proxy matcher (only if you broaden it)

TurboStarter’s src/proxy.ts currently matches docs paths only, so Workflow internals are unaffected by default. If you later expand the matcher to cover the whole app, exclude Workflow’s well-known paths or local queue operations can fail with cryptic ArrayBuffer errors:

apps/web/src/proxy.ts
export const config = {
  matcher: [
    {
      source:
        "/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)",
    },
  ],
};

Create a durable workflow

Keep orchestration in the workflow function and I/O (database, email, third-party APIs) inside step functions. Pass a stored job ID, not a browser-supplied user object.

apps/web/src/workflows/process-stored-job.ts
import { FatalError } from "workflow";

import { processStoredJob } from "~/lib/tasks/process-stored-job";

export async function processStoredJobWorkflow(jobId: string): Promise<void> {
  "use workflow";

  await processJobStep(jobId);
}

async function processJobStep(jobId: string): Promise<void> {
  "use step";

  try {
    await processStoredJob(jobId);
  } catch (error) {
    // Permanent validation failures should not spin forever
    if (error instanceof Error && error.message.startsWith("Invalid job")) {
      throw new FatalError(error.message);
    }
    throw error; // Retryable by default
  }
}

processStoredJob (for example in apps/web/src/lib/tasks/process-stored-job.ts) is your product code. It should:

  1. Load the pending job (and organization) from the database
  2. Claim it atomically (pendingprocessing)
  3. Perform the work idempotently
  4. Record success or failure

Steps may retry, so duplicate side effects are bugs.

Sleeps and waits

Use Workflow primitives when the orchestrator must pause without burning compute:

import { sleep } from "workflow";

export async function onboardingWorkflow(userId: string) {
  "use workflow";

  await sendWelcomeStep(userId);
  await sleep("2d");
  await sendTipsStep(userId);
}

Put fetch, DB, and Node APIs inside "use step" functions. The workflow body should stay deterministic orchestration.

Start workflows after authorization

Start runs with start from workflow/api. Do not call the workflow function as a normal async function from a route if you want durable execution.

packages/api/src/modules/tasks/tasks.router.ts
import { TRPCError } from "@trpc/server";
import * as z from "zod";

import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { claimJobForUser } from "./tasks.service";

export const tasksRouter = createTRPCRouter({
  startProcessJob: protectedProcedure
    .input(z.object({ jobId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      // Product-specific: verify this session may operate the job
      const job = await claimJobForUser({
        jobId: input.jobId,
        userId: ctx.session.user.id,
      });

      if (!job) {
        throw new TRPCError({ code: "FORBIDDEN" });
      }

      // Import dynamically if the API package should not depend on workflow at build time,
      // or call a thin web-app route that owns `start()`.
      return { jobId: job.id, status: "authorized" as const };
    }),
});

Because start() belongs with the Next.js Workflow runtime, a clean split is: authorize in tRPC, then start from a Route Handler in the web app (or a server action that only runs in apps/web).

From a Route Handler

apps/web/src/app/api/tasks/process/route.ts
import { NextResponse } from "next/server";
import * as z from "zod";
import { start } from "workflow/api";

import { auth } from "@workspace/auth/server";

import { processStoredJobWorkflow } from "~/workflows/process-stored-job";

const requestSchema = z.object({
  jobId: z.string().uuid(),
});

export async function POST(request: Request): Promise<Response> {
  const session = await auth.api.getSession({ headers: request.headers });

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { jobId } = requestSchema.parse(await request.json());

  // Verify session.user can operate this stored job before continuing.
  // Re-check access inside delayed steps if permissions can change.

  const run = await start(processStoredJobWorkflow, [jobId]);

  return NextResponse.json({ runId: run.runId }, { status: 202 });
}

Authorization is required, not sample fluff. Skipping it is an IDOR waiting to happen. Prefer “job ID + membership check” over shipping a full organization DTO into the workflow.

Inspect and test the lifecycle

npx workflow health
npx workflow web
npx workflow inspect runs

On Vercel, open Observability → Workflows. Still keep application job status in your database so support and customers are not tied to provider-specific run UIs.

Test more than the happy path

  1. An authenticated member can start a job they own
  2. Another user cannot start that job
  3. A transient step failure retries without duplicating an external side effect
  4. A permanent failure (FatalError) becomes visible and does not stay pending forever
  5. Redeploying while a workflow is paused does not lose the run
  6. Starting the same stored job twice does not process it twice

Production checklist

  • Pin a reviewed Workflow SDK version; upgrade deliberately after reading changelogs
  • Keep step inputs small: IDs, not secrets or full customer records
  • Log stable job and organization identifiers
  • Define which errors retry vs fail permanently (FatalError)
  • Alert on failed runs and jobs stuck in processing too long
  • Document how support can safely replay or cancel a job
  • Confirm Workflow pricing and limits before launch
  • Include src/app/.well-known/workflow/** in Turborepo build outputs

Best practices

FAQ

Next steps

With Workflows integrated you can:

  • Run durable multi-step jobs that resume after deploys
  • Pause with sleep without holding a serverless invocation open
  • Keep authorization in TurboStarter’s session layer while orchestration stays in-repo
  • Inspect runs via the Workflow CLI and Vercel Observability

Continue with the Workflow SDK documentation for hooks, streaming, and advanced control flow, or compare options in the background tasks overview.

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter