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:
- Ingest — accept an upload and enqueue background processing
- Analyze — AI-powered content extraction with concurrency control
- Approve — pause and wait for a human reviewer before publishing
- Notify — resume and send a confirmation when approved
Step 1: Install the v4 SDK
npm install @trigger.dev/sdk
npx trigger.dev@latest initThe 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_xxxxxxxxxxxxStep 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:
onStartAttemptis the v4 successor to the deprecatedonStart. UseonStartAttemptfor 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 devThis 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 deployGitHub 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 changed | v3 | v4 |
|---|---|---|
| Import path | @trigger.dev/sdk/v3 | @trigger.dev/sdk |
| Hook params | positional args | single destructured object |
| Hook name | onStart | onStartAttempt (preferred) |
| Batch results | batchHandle.runs | batch.retrieve(id).runs |
| Approval flows | not available | wait.createToken + wait.forToken |
| AI tool bridge | not available | schemaTask + ai.toolExecute |
| Pause/resume hooks | not available | onWait, 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
- LanceDB vector search in Next.js — store and query AI-extracted document embeddings
- AI SDK 7 HarnessAgent — orchestrate Trigger.dev
schemaTasktools withHarnessAgent - LiteLLM Proxy gateway — route AI calls through a self-hosted gateway with per-team budgets
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.