EmpireUI
Get Pro
← Blog9 min read#trigger.dev#background jobs#queue

Trigger.dev Guide: Background Jobs, Schedules and Retries in Next.js

Learn how to run background jobs, cron schedules, and retry logic in Next.js using Trigger.dev v3 — with real code and zero serverless timeout headaches.

Terminal screen showing asynchronous background job queue task execution

Why Background Jobs Are Painful in Next.js

Next.js API routes have a hard serverless timeout ceiling — usually 10 seconds on Vercel's Hobby plan, 60 seconds on Pro. That's fine for CRUD. It's completely useless if you're sending bulk emails, generating PDFs, syncing data from a third-party API, or doing anything that takes more than a minute.

The old solution was to spin up a separate worker service — BullMQ on Redis, a Heroku worker dyno, whatever. That works, but it adds infra overhead, another deployment target, and yet another thing to monitor at 2am. Trigger.dev v3 (released publicly in 2024 and now at v3.2 as of 2026) takes a different approach: your tasks live in your Next.js repo, they're just not executed inside a request handler.

Honestly, the mental model shift is the thing that trips people up. You're not replacing your API route — you're triggering a separate, long-running process from your API route. The route returns immediately; the job does the heavy lifting on Trigger.dev's infrastructure. Once that clicks, everything else makes sense.

In this guide you'll wire up a Trigger.dev project from scratch inside a Next.js 14+ App Router app, write your first task, schedule it with cron, configure retries, and handle failure states. No hand-waving — actual code you can copy.

Installation and Project Setup

Start with the CLI. It's the fastest way to scaffold the integration without reading 40 pages of docs.

npx trigger.dev@latest init

This drops a trigger.config.ts at your project root and creates a src/trigger/ directory. It also adds the @trigger.dev/sdk package and a dev script. Your package.json gains a trigger:dev command that you run alongside next dev. Worth noting: you need a free Trigger.dev account and a project ID before the CLI will finish — grab those at trigger.dev before running init.

// trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk/v3";

export default defineConfig({
  project: "proj_yourprojectid",
  runtime: "node",
  logLevel: "log",
  maxDuration: 300, // 5 minutes per task
  retries: {
    enabledInDev: false,
    default: {
      maxAttempts: 3,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
    },
  },
  dirs: ["./src/trigger"],
});

That maxDuration: 300 line is doing a lot of work. Your tasks can now run for up to 5 minutes by default, and you can override it per-task up to Trigger.dev's plan limits. Compare that to the 10-second serverless timeout you'd be fighting otherwise. Quick aside: if you're on the free tier, the limit is 15 minutes per task, which is enough for the vast majority of workloads.

Writing Your First Task

Tasks live in files inside the dirs you configured. Any file that exports a task() is automatically picked up. No registration step, no barrel file to maintain.

// src/trigger/send-welcome-email.ts
import { task } from "@trigger.dev/sdk/v3";
import { sendEmail } from "@/lib/email"; // your mailer of choice

export const sendWelcomeEmail = task({
  id: "send-welcome-email",
  maxDuration: 60,
  run: async (payload: { userId: string; email: string; name: string }) => {
    // This runs on Trigger.dev's infra — not inside your serverless function
    await sendEmail({
      to: payload.email,
      subject: `Welcome, ${payload.name}!`,
      html: `<p>Hi ${payload.name}, your account is ready.</p>`,
    });

    return { success: true, sentTo: payload.email };
  },
});

The id has to be unique across your project. Keep it kebab-case and descriptive — you'll reference it in the dashboard when debugging failed runs. The run function is fully async, you can await anything inside it, and whatever you return gets stored as the task's output metadata.

Now trigger it from your API route or Server Action. The key thing: .trigger() returns immediately after enqueuing the job. Your route doesn't block.

// src/app/api/signup/route.ts
import { sendWelcomeEmail } from "@/trigger/send-welcome-email";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { userId, email, name } = await req.json();

  // save user to DB...

  // fire and forget — returns a run handle, not a promise of the email being sent
  const handle = await sendWelcomeEmail.trigger({ userId, email, name });

  return NextResponse.json({ ok: true, jobId: handle.id });
}

In practice, you almost never need to await the result of a job from the triggering context. The run handle gives you an ID you can store and poll later if you need to surface progress to the user.

Cron Schedules

Trigger.dev has first-class cron support. You define a scheduled task the same way you define a regular task — just swap task() for schedules.task() and add a cron expression.

// src/trigger/daily-report.ts
import { schedules } from "@trigger.dev/sdk/v3";

export const dailyReport = schedules.task({
  id: "daily-report",
  // runs every day at 08:00 UTC
  cron: "0 8 * * *",
  maxDuration: 120,
  run: async (payload) => {
    // payload.lastRunAt — when this task last ran (useful for delta queries)
    const since = payload.lastRunAt ?? new Date(Date.now() - 86_400_000);

    // fetch your data, build report, send email...
    console.log("Running report for period since", since.toISOString());

    return { reportedAt: new Date().toISOString() };
  },
});

The payload.lastRunAt field is genuinely useful — it saves you from hardcoding time windows. If a run was delayed or skipped (say, your deployment was broken), the next run still knows where to pick up from. That said, it's only populated after the first successful run, so make sure you handle the null case like the example above.

You can also create schedules dynamically — say, one per user who opts into weekly digests — using schedules.create(). Look, most tutorials skip this and only show the static cron approach, but dynamic schedules are where Trigger.dev really pulls ahead of a simple cron-job.org setup.

// Create a per-user schedule programmatically
import { schedules } from "@trigger.dev/sdk/v3";

await schedules.create({
  task: "daily-report", // references the task id
  cron: "0 9 * * 1",  // every Monday at 09:00 UTC for this user
  timezone: "America/New_York",
  externalId: `user-report-${userId}`, // lets you find/delete it later
  deduplicationKey: `user-report-${userId}`,
});

Retry Configuration and Error Handling

The default retry config from trigger.config.ts applies to all tasks, but you can override it per-task. Exponential backoff with jitter is the right default for anything hitting external APIs — you don't want 500 failed email tasks hammering your SMTP server in the same second.

import { task, retry } from "@trigger.dev/sdk/v3";

export const processWebhook = task({
  id: "process-webhook",
  retry: {
    maxAttempts: 5,
    minTimeoutInMs: 500,
    maxTimeoutInMs: 30_000,
    factor: 2,
    randomize: true, // jitter — highly recommended
  },
  run: async (payload: { eventType: string; data: unknown }) => {
    // If this throws, Trigger.dev catches it and schedules a retry
    const result = await processEvent(payload);
    return result;
  },
});

Any unhandled exception inside run triggers a retry. That's usually what you want, but sometimes you have expected failures that shouldn't retry — a user-not-found error, for instance. Wrap those in a check and return early instead of throwing.

For partial progress, use task.io to emit checkpoints. If your task gets interrupted mid-way (rare but possible on long tasks), the checkpointed data is preserved.

import { task, logger } from "@trigger.dev/sdk/v3";

export const bulkImport = task({
  id: "bulk-import",
  maxDuration: 300,
  run: async (payload: { rows: Record<string, unknown>[] }) => {
    let imported = 0;

    for (const row of payload.rows) {
      try {
        await importRow(row);
        imported++;
      } catch (err) {
        // log but don't throw — continue processing other rows
        logger.warn("Row failed", { row, err });
      }
    }

    logger.info(`Import complete: ${imported}/${payload.rows.length}`);
    return { imported, total: payload.rows.length };
  },
});

One more thing — the Trigger.dev dashboard shows you every run, the input payload, the output, each retry attempt, and the log output. You don't need to add extra logging infrastructure just to debug background jobs. This alone saves a few hours per incident when something goes wrong in production.

Triggering Jobs from Server Actions and Waitlists

Server Actions are a natural fit for triggering background jobs — the user submits a form, the action validates, saves to DB, fires the job, and returns. No client-side polling needed for the happy path.

// src/app/actions/submit-form.ts
"use server";

import { sendWelcomeEmail } from "@/trigger/send-welcome-email";
import { generateReport } from "@/trigger/generate-report";

export async function submitOnboarding(formData: FormData) {
  const email = formData.get("email") as string;
  const name = formData.get("name") as string;

  // save to db, get userId back
  const user = await createUser({ email, name });

  // trigger multiple jobs in parallel
  await Promise.all([
    sendWelcomeEmail.trigger({ userId: user.id, email, name }),
    generateReport.trigger({ userId: user.id, reportType: "onboarding" }),
  ]);

  return { ok: true };
}

If you need to wait for a job's result before responding (say, a synchronous PDF generation for a download link), use .triggerAndWait(). This keeps the connection open — so it's only appropriate in contexts that can tolerate latency, like an internal admin action, not a public form submit that needs to respond in under 200ms.

Worth noting: .triggerAndWait() uses Trigger.dev's infrastructure to suspend and resume your Server Action, not a long-lived HTTP connection. Your serverless function isn't sitting there blocked. The SDK handles the polling and callback under the hood, so you stay within your serverless timeout budget even for 2-minute jobs.

The same patterns work with React's useFormState hook for surfacing job progress back to the UI — you'd store the run ID in your DB after triggering, then poll a /api/job-status?runId=... endpoint that calls runs.retrieve(runId). The Empire UI component library has skeleton loaders and progress indicators that slot in nicely for these in-flight states.

Local Dev and Deployment

For local development, run both next dev and trigger:dev in separate terminals. The Trigger.dev CLI creates a tunnel from the cloud to your local machine, so triggered jobs actually execute locally. This is the right way to test — no mocks, no stubs, real execution.

# Terminal 1
npm run dev

# Terminal 2
npx trigger.dev@latest dev

Deployment is just part of your normal build. Add your TRIGGER_SECRET_KEY environment variable to Vercel (or whatever host), and Trigger.dev automatically detects new task definitions on each deploy. No separate worker deployment step. The tasks are bundled and uploaded to Trigger.dev's cloud as part of a one-time sync that happens when you deploy.

If you're using Docker — check out the docker-nextjs-production guide for the base setup — you just need to add TRIGGER_SECRET_KEY to your environment and make sure your image includes the src/trigger/ directory. The Trigger.dev SDK handles the rest at runtime.

One gotcha: if you're using path aliases like @/lib/... inside your task files, make sure tsconfig.json paths are set up correctly and that trigger.config.ts doesn't strip them during bundling. As of Trigger.dev v3.2, aliases work out of the box if you're using the default node runtime. You can also browse the blog for related guides on OpenTelemetry, Sentry, and other observability tools that pair well with background job pipelines.

FAQ

Does Trigger.dev work with the Next.js App Router?

Yes, fully. You trigger jobs from Server Actions, API Route Handlers, or any server-side code. The SDK works wherever Node.js runs — it's not tied to a specific Next.js version, though v14+ with App Router is where it fits most naturally.

How is Trigger.dev different from using BullMQ or a Redis queue?

BullMQ requires you to manage a Redis instance and a separate worker process. Trigger.dev handles the infra — you write task files in your repo and they run on Trigger.dev's cloud. Less ops overhead, built-in retries dashboard, no Redis bill. The tradeoff is vendor dependency and egress costs for very high volumes.

Can I run Trigger.dev tasks on a self-hosted server?

Trigger.dev v3 is primarily a cloud service, but they offer an open-source self-hosted option via their GitHub repo. As of 2026 it's still more operationally involved than using the cloud — you need to run their platform services yourself. For most teams, the cloud tier is the right starting point.

What happens if a task fails all its retries?

The run is marked as failed in the Trigger.dev dashboard and you'll see the error and full stack trace there. You can configure alert webhooks or email notifications for failed runs. You can also replay failed runs manually from the dashboard with the same original payload.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Inngest Guide: Event-Driven Functions and Workflows in Next.jsBetter Auth Guide 2026: Sessions, Social, 2FA in Next.jsTrigger.dev vs Inngest: Background Jobs for Next.js ComparedService Workers in Next.js: Offline Support, Background Sync