writing/tutorial/2026/07
TutorialJul 24, 2026·30 min read

Trigger.dev v4: Background Jobs, Approval Flows, and AI Tasks in Next.js

Upgrade from the retired Trigger.dev v3 to v4 and build a document processing pipeline with wait-for-token approval flows, AI SDK tool bridges via schemaTask, and concurrency-controlled queues in Next.js 15.

Why Trigger.dev v4?

Trigger.dev v3 reached end-of-life on July 1, 2026. If your project still imports from @trigger.dev/sdk/v3, your background tasks have stopped executing — the v3 cloud infrastructure is fully shut down.

v4 is not a cosmetic upgrade. It rewrites the hook API, ships a middleware system, adds wait-for-token approval flows, and introduces schemaTask — a bridge that turns any background job into a first-class Vercel AI SDK tool. This tutorial walks you through the migration and builds a complete document processing pipeline showcasing every major v4 feature.

Prerequisites

  • Node.js 20+
  • A Next.js 15 project (App Router)
  • A Trigger.dev account — free at trigger.dev
  • Basic familiarity with async TypeScript

What You'll Build

A document intake pipeline with four stages:

  1. Ingest — accept an upload and enqueue background processing
  2. Analyze — AI-powered content extraction with concurrency control
  3. Approve — pause and wait for a human reviewer before publishing
  4. Notify — resume and send a confirmation when approved

Step 1: Install the v4 SDK

npm install @trigger.dev/sdk
npx trigger.dev@latest init

The init command writes a trigger.config.ts at your project root and creates a trigger/ directory for task files.

If you are migrating from v3, the first change is the import path:

// Before (v3)
import { task } from "@trigger.dev/sdk/v3";
 
// After (v4)
import { task } from "@trigger.dev/sdk";

Configure your project in trigger.config.ts:

// trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk/config";
 
export default defineConfig({
  project: "proj_YOUR_PROJECT_ID",
  runtime: "node",
  maxDuration: 3600,
  dirs: ["./trigger"],
});

Add your API key to .env.local:

TRIGGER_SECRET_KEY=tr_dev_xxxxxxxxxxxx

Step 2: Your First v4 Task

The most significant breaking change in v4 is the hook parameter API. In v3, hooks received separate positional arguments. In v4, every hook receives a single destructured object.

// trigger/document-tasks.ts
import { task, logger } from "@trigger.dev/sdk";
 
export const ingestDocument = task({
  id: "ingest-document",
 
  // v4: single object parameter for all hooks
  onStartAttempt: ({ payload, ctx }) => {
    logger.info("Starting ingest attempt", { runId: ctx.run.id });
  },
 
  onSuccess: ({ payload, output, ctx }) => {
    logger.info("Ingest succeeded", { docId: output.documentId });
  },
 
  onFailure: ({ payload, error, ctx }) => {
    logger.error("Ingest failed", { message: error.message });
  },
 
  // run() signature is unchanged from v3
  run: async (payload: { filename: string; url: string }, { ctx }) => {
    logger.info("Processing document", { filename: payload.filename });
 
    await new Promise(resolve => setTimeout(resolve, 1000));
 
    return {
      documentId: `doc_${Date.now()}`,
      filename: payload.filename,
      status: "ingested",
    };
  },
});

Note: onStartAttempt is the v4 successor to the deprecated onStart. Use onStartAttempt for all new code.

Step 3: Trigger from Next.js

Server Action

// app/actions/document.ts
"use server";
 
import { tasks } from "@trigger.dev/sdk";
import type { ingestDocument } from "@/trigger/document-tasks";
 
export async function submitDocument(filename: string, url: string) {
  try {
    const handle = await tasks.trigger<typeof ingestDocument>(
      "ingest-document",
      { filename, url }
    );
    return { runId: handle.id };
  } catch (error) {
    return { error: "Failed to enqueue document" };
  }
}

API Route

// app/api/documents/route.ts
import { tasks } from "@trigger.dev/sdk";
import { NextResponse } from "next/server";
import type { ingestDocument } from "@/trigger/document-tasks";
 
export async function POST(request: Request) {
  const { filename, url } = await request.json();
 
  const handle = await tasks.trigger<typeof ingestDocument>(
    "ingest-document",
    { filename, url }
  );
 
  return NextResponse.json({ runId: handle.id });
}

The type-only import ensures the task code never ships to the client bundle.

Step 4: Queues and Concurrency

Use queue() to control how many runs execute simultaneously — critical for protecting your database or downstream APIs.

// trigger/analysis-tasks.ts
import { task, queue } from "@trigger.dev/sdk";
 
const processingQueue = queue({
  name: "document-processing",
  concurrencyLimit: 5,
});
 
export const analyzeDocument = task({
  id: "analyze-document",
  queue: processingQueue,
 
  retry: {
    maxAttempts: 3,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 30000,
    factor: 2,
  },
 
  run: async (payload: { documentId: string; url: string }) => {
    const text = await fetch(payload.url).then(r => r.text());
    const wordCount = text.split(/\s+/).length;
 
    return {
      documentId: payload.documentId,
      wordCount,
      extractedAt: new Date().toISOString(),
    };
  },
});

You can also override the queue per-trigger for priority routing:

// Route paid users to a higher-concurrency queue
const handle = await analyzeDocument.trigger(
  { documentId, url },
  { queue: "premium-users" }
);

Step 5: Wait for Token — Human Approval Flow

v4's most powerful new primitive is the waitpoint token. A task pauses mid-execution and resumes only when an external system completes that token — minutes, hours, or even days later — without occupying a server thread.

// trigger/approval-tasks.ts
import { task, wait } from "@trigger.dev/sdk";
 
export const awaitDocumentApproval = task({
  id: "await-document-approval",
  maxDuration: 86400, // pause for up to 24 hours
 
  run: async (payload: { documentId: string; reviewerEmail: string }) => {
    // Create a token scoped to this run
    const token = await wait.createToken({
      timeout: "24h",
      tags: [`doc:${payload.documentId}`],
    });
 
    // Send an email containing a link with token.id
    await notifyReviewer(payload.reviewerEmail, payload.documentId, token.id);
 
    // Task pauses here — resumes when the token is completed
    const result = await wait.forToken<{ approved: boolean; notes: string }>(token);
 
    if (!result.output.approved) {
      throw new Error(`Document rejected: ${result.output.notes}`);
    }
 
    return { approved: true, notes: result.output.notes };
  },
});
 
async function notifyReviewer(email: string, docId: string, tokenId: string) {
  // Send email with link: /api/review?token=tokenId&doc=docId
  console.log(`Approval link sent to ${email} for doc ${docId}`);
}

Complete the token from an API route

// app/api/review/route.ts
import { runs } from "@trigger.dev/sdk";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { tokenId, approved, notes } = await request.json();
 
  // Complete the waitpoint — task resumes immediately
  await runs.completeToken(tokenId, { approved, notes });
 
  return NextResponse.json({ ok: true });
}

The wait.forToken primitive handles any async handoff that would otherwise require polling or webhooks.

Step 6: schemaTask as an AI SDK Tool

v4 introduces schemaTask — a task variant with a declared Zod schema. This enables ai.toolExecute, which converts any background job into a tool callable by language models via the Vercel AI SDK.

// trigger/ai-tasks.ts
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
 
export const summarizeDocument = schemaTask({
  id: "summarize-document",
  description: "Extract a summary and key topics from a document URL",
  schema: z.object({
    documentId: z.string(),
    url: z.string().url(),
    maxWords: z.number().int().min(50).max(500).default(200),
  }),
  run: async ({ documentId, url, maxWords }) => {
    const text = await fetch(url).then(r => r.text());
    const words = text.split(/\s+/).slice(0, maxWords);
    return {
      documentId,
      summary: words.join(" "),
      topicCount: Math.min(3, Math.floor(words.length / 50)),
    };
  },
});

Wire it as a Vercel AI SDK tool

// lib/document-agent.ts
import { generateText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { ai } from "@trigger.dev/sdk/ai";
import { summarizeDocument } from "@/trigger/ai-tasks";
 
const summarizeTool = tool({
  description: summarizeDocument.description ?? "Summarize a document",
  inputSchema: summarizeDocument.schema!,
  execute: ai.toolExecute(summarizeDocument),
});
 
export async function runDocumentAgent(userPrompt: string) {
  const { text } = await generateText({
    model: anthropic("claude-sonnet-5"),
    prompt: userPrompt,
    tools: { summarizeDocument: summarizeTool },
    maxSteps: 5,
  });
  return text;
}

When the model invokes summarizeDocument, the Trigger.dev task runs in the background — with retries, concurrency control, and real-time monitoring — instead of executing inline in your API route.

Step 7: Scheduled (Cron) Tasks

// trigger/scheduled-tasks.ts
import { schedules } from "@trigger.dev/sdk";
 
export const dailyCleanup = schedules.task({
  id: "daily-cleanup",
  cron: "0 2 * * *", // 02:00 UTC every day
 
  run: async (payload) => {
    const { timestamp, lastTimestamp } = payload;
 
    const cutoff = new Date(timestamp);
    cutoff.setDate(cutoff.getDate() - 30);
 
    // payload.lastTimestamp is undefined on the first run
    const since = lastTimestamp ?? cutoff;
 
    const deleted = await deleteDocumentsOlderThan(cutoff);
    return { deleted, ranAt: timestamp.toISOString(), since: since.toISOString() };
  },
});
 
async function deleteDocumentsOlderThan(cutoff: Date): Promise<number> {
  // Your database query here
  return 0;
}

No extra configuration is needed — the cron field registers the schedule automatically on deploy.

Step 8: New v4 Lifecycle Hooks

v4 adds hooks that fire around waitpoint boundaries, giving you observability into paused runs:

// trigger/monitored-task.ts
import { task, logger } from "@trigger.dev/sdk";
 
export const monitoredTask = task({
  id: "monitored-task",
 
  // Fires when the run enters a waitpoint (wait.forToken, wait.forEvent, etc.)
  onWait: ({ payload, ctx }) => {
    logger.info("Task paused on waitpoint", { runId: ctx.run.id });
  },
 
  // Fires when the waitpoint resolves and the run resumes
  onResume: ({ payload, ctx }) => {
    logger.info("Task resumed from waitpoint", { runId: ctx.run.id });
  },
 
  // Fires after the run function returns successfully
  onComplete: ({ payload, output, ctx }) => {
    logger.info("Task completed", { runId: ctx.run.id, output });
  },
 
  // Fires when the run is cancelled via the dashboard or API
  onCancel: ({ payload, ctx }) => {
    logger.warn("Task cancelled", { runId: ctx.run.id });
  },
 
  run: async (payload: { documentId: string }) => {
    return { processed: payload.documentId };
  },
});

These hooks complement the existing onSuccess, onFailure, and onStartAttempt hooks and are useful for emitting metrics to Datadog, PostHog, or any observability sink.

Step 9: Batch Triggering (v4 API Change)

The batch API changed in v4. You now retrieve results with batch.retrieve() instead of accessing batchHandle.runs directly:

import { tasks, batch } from "@trigger.dev/sdk";
import type { ingestDocument } from "@/trigger/document-tasks";
 
const documents = [
  { filename: "report-a.pdf", url: "https://example.com/a.pdf" },
  { filename: "report-b.pdf", url: "https://example.com/b.pdf" },
  { filename: "report-c.pdf", url: "https://example.com/c.pdf" },
];
 
const batchHandle = await tasks.batchTrigger(
  documents.map(doc => [ingestDocument, doc] as const)
);
 
// v3: batchHandle.runs — REMOVED in v4
// v4: retrieve separately
const batchResult = await batch.retrieve(batchHandle.batchId);
console.log(`Queued ${batchResult.runs.length} runs`);

Step 10: Develop and Deploy

Local development

npx trigger.dev@latest dev

This command streams task logs to your terminal and hot-reloads task files on save. Your Next.js dev server runs separately.

Deploy to Trigger.dev Cloud

npx trigger.dev@latest deploy

GitHub Actions CI

- name: Deploy Trigger.dev tasks
  run: npx trigger.dev@latest deploy --ci
  env:
    TRIGGER_SECRET_KEY: ${{ secrets.TRIGGER_SECRET_KEY }}

Migration Reference: v3 to v4

What changedv3v4
Import path@trigger.dev/sdk/v3@trigger.dev/sdk
Hook paramspositional argssingle destructured object
Hook nameonStartonStartAttempt (preferred)
Batch resultsbatchHandle.runsbatch.retrieve(id).runs
Approval flowsnot availablewait.createToken + wait.forToken
AI tool bridgenot availableschemaTask + ai.toolExecute
Pause/resume hooksnot availableonWait, onResume, onComplete, onCancel

Troubleshooting

Task not running: confirm TRIGGER_SECRET_KEY matches your project. Run npx trigger.dev@latest whoami to verify the connection.

Run stuck in "waiting" state: a waitpoint token was created but never completed. Call runs.completeToken from your approval endpoint, or cancel the run from the Trigger.dev dashboard.

TypeScript errors on hook params: you are still using v3-style positional arguments. Wrap them in a single destructured object as shown in Step 2.

batchHandle.runs is undefined: you are using the v3 batch API. Migrate to batch.retrieve(batchHandle.batchId) as shown in Step 9.

Next Steps

Conclusion

Trigger.dev v4 builds meaningfully on v3: a cleaner hook API, wait-for-token for async human-in-the-loop flows, schemaTask for AI tool integration, and new lifecycle hooks for observability around waitpoints. With v3 fully shut down as of July 2026, migration is not optional — but the upgrade is straightforward, and the new features justify the effort.