Defining and triggering Trigger.dev tasks
The core Trigger.dev v4 SDK for defining typed tasks, triggering them fire-and-forget, capping concurrency with queues and per-tenant keys, and scheduling work.
You’ve decided this work belongs on Trigger.dev (previous lesson). This lesson covers the smallest SDK surface for defining, typing, triggering, queuing, and scheduling a task.
Watch for one hazard throughout: the move from Trigger.dev v3 to v4 broke a handful of APIs, and the old shapes are exactly what a search result, blog post, or AI completion hands you. Copy one and your code deploys clean, then behaves wrong.
A task is its own world. It does not run inside a request, so there is no session, cookie, or signed-in user, only the payload you pass it. Everything the task needs travels in that payload, above all which organization it is acting for.
Where task code lives: trigger.config.ts and dirs
Section titled “Where task code lives: trigger.config.ts and dirs”Trigger.dev is not a library you npm install and call. It’s a platform with its own deploy pipeline, and it finds your tasks by scanning a folder. One command sets that up:
npx trigger.dev@latest initThis one-time command links a Trigger.dev cloud project, writes a trigger.config.ts at your repository root, and creates the trigger/ folder your tasks go in. The config declares three things: the project ref , the runtime, and dirs, the field that decides where tasks are found.
import { defineConfig } from '@trigger.dev/sdk';
export default defineConfig({ project: 'proj_abcdefgh1234', dirs: ['./trigger'], runtime: 'node',});dirs is the one config line worth understanding rather than copying. Trigger.dev only registers task files inside the folders listed in dirs ; a file outside them is ignored with no error and no warning, so the task never appears in the dashboard and never runs. The course default, also the v4 default, is a single root-level trigger/ folder, one task per file.
- trigger.config.ts lives at the repo root
Directorytrigger/ task files, found via
dirs- notify-org-members.ts
- export-csv.ts
Directoryapp/ your Next.js routes and Server Actions
- …
Directorylib/ shared helpers, tasks import these too
- email.ts
Directorydb/ the Drizzle schema tasks read from
- schema.ts
- package.json
Tasks live in the same repo as your app and share everything with it, the same Drizzle schema and the same lib/email.ts. They just occupy their own folder and deploy on their own pipeline.
Two commands drive the development loop: npx trigger.dev@latest dev runs a local worker that registers your tasks and streams their logs to your terminal, and npx trigger.dev@latest deploy ships them to the cloud. A Trigger.dev app deploys in two steps from one codebase, your tasks and your Next.js app, and the order between them matters; that’s for a later lesson.
Defining a task: task and schemaTask
Section titled “Defining a task: task and schemaTask”A task is the unit you define and call: an object with two required fields, an id and a run function. The base primitive is worth seeing once, because what it lacks is the reason for the version you’ll actually use.
import { task } from '@trigger.dev/sdk';
export const notifyOrgMembers = task({ id: 'notify-org-members', run: async (payload, { ctx }) => { // payload is untyped here — you trust it or hand-check it },});The id is the task’s durable identity . Treat it like a route path or a database table name: stable, code-reviewed, never casually renamed.
The run function is the body. It receives the payload and a context object, but on a bare task the payload is untyped: Trigger.dev hands you whatever the caller sent, so you trust it blindly or hand-check it. That unguarded boundary is what schemaTask closes.
schemaTask is task plus one field, a schema. You hand it a Zod object, and Trigger.dev parses the payload against it before run executes. An invalid payload throws the moment you trigger, not three minutes into a run that has already burned the compute. It’s the discipline you applied to Server Actions, with the trigger call as the boundary instead of the form submission.
import { task } from '@trigger.dev/sdk';
export const notifyOrgMembers = task({ id: 'notify-org-members', run: async (payload, { ctx }) => { const orgId = payload.organizationId; // any — no guarantee it exists },});payload.organizationId is untyped: nothing checked it, so you defend the boundary inside every run.
import { schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', schema: z.object({ organizationId: z.uuid(), eventType: z.string(), }), run: async (payload, { ctx }) => { const orgId = payload.organizationId; // string, guaranteed },});The schema validates before run, so payload is fully typed and a bad payload is rejected at trigger time. It also renders in the dashboard as the task’s input contract.
Keep the schema inline, next to the task, so the task and its contract share one file. Hoist it to lib/triggers/<task>.schema.ts only when a caller needs to import it, which is rare.
Trigger.dev v4 accepts any Standard Schema validator, so Zod, Valibot, and ArkType all work; the course uses Zod 4.
import { schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', schema: z.object({ organizationId: z.uuid(), eventType: z.string(), }), run: async (payload, { ctx }) => { const { organizationId, eventType } = payload; // ctx.run.id, ctx.attempt.number, ctx.environment — never headers() },});The durable id — the one field you can’t change without orphaning past runs.
import { schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', schema: z.object({ organizationId: z.uuid(), eventType: z.string(), }), run: async (payload, { ctx }) => { const { organizationId, eventType } = payload; // ctx.run.id, ctx.attempt.number, ctx.environment — never headers() },});The schema: parsed before run, and rendered in the dashboard as the task’s documented input.
import { schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', schema: z.object({ organizationId: z.uuid(), eventType: z.string(), }), run: async (payload, { ctx }) => { const { organizationId, eventType } = payload; // ctx.run.id, ctx.attempt.number, ctx.environment — never headers() },});Inside run, payload is typed from the schema — organizationId and eventType destructured with no cast and no parallel interface. The schema is the type.
import { schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', schema: z.object({ organizationId: z.uuid(), eventType: z.string(), }), run: async (payload, { ctx }) => { const { organizationId, eventType } = payload; // ctx.run.id, ctx.attempt.number, ctx.environment — never headers() },});The second argument, ctx, is per-run context: ctx.run.id, ctx.attempt.number (which retry this is), and ctx.environment. It is not request context — no headers(), no cookies(), no requireOrgUser() — which is why organizationId rides in on the payload.
ctx is easy to misread as “the request,” but there is no request: the task ran because something enqueued it, perhaps minutes ago, perhaps after the user who triggered it closed the tab. Nobody is signed in, so the organization id is the first field in every payload you write. (ctx.run.id becomes an idempotency key next lesson.)
The schema is the one part of a task that runs in a browser, so write it here. Define the Zod schema for the export task below and watch the scenarios flip as you get it right.
An export task needs three things in its payload: organizationId as a UUID, a since ISO date (a calendar day, not a datetime), and an optional format that is either 'csv' or 'json', defaulting to 'csv'. Write the schema using the v4 top-level builders (z.uuid(), z.iso.date(), z.enum) — not .string().x() chains. The valid-csv-default scenario passes only once the format default applies.
| Test scenario | Value | |
|---|---|---|
| valid csv default | {"organizationId":"018f1a2b-3c4d-7e5f-8a9b-0c1d2e3f4a5b",… | |
| explicit json | {"organizationId":"018f1a2b-3c4d-7e5f-8a9b-0c1d2e3f4a5b",… | |
| bad uuid | {"organizationId":"not-a-uuid","since":"2026-01-01"} | |
| bad format | {"organizationId":"018f1a2b-3c4d-7e5f-8a9b-0c1d2e3f4a5b",… | |
Reference solution
import { z } from 'zod';
export const ExportPayload = z.object({ organizationId: z.uuid(), since: z.iso.date(), format: z.enum(['csv', 'json']).default('csv'),});
type ExportPayload = z.infer<typeof ExportPayload>;z.uuid() and z.iso.date() are the Zod 4 top-level format builders; z.iso.date() accepts a calendar day like '2026-01-01' but rejects a full datetime. .default('csv') makes format optional and supplies 'csv' when it’s absent, which is why the first scenario parses with no format field.
Triggering a task from your app
Section titled “Triggering a task from your app”A task no one calls is dead code. You trigger it by importing the task and calling a method on it.
'use server';
import { exportCsv } from '@/trigger/export-csv';import { requireOrgUser } from '@/lib/auth';import { ok } from '@/lib/result';
export const startExport = async (since: string) => { const { orgId } = await requireOrgUser(); const handle = await exportCsv.trigger({ organizationId: orgId, since }); return ok({ runId: handle.id });};Because you imported the typed task, the payload is type-checked against its schema at the call site: pass the wrong shape and TypeScript complains before you run. No string id to mistype, no generic to wire by hand.
trigger returns a handle , a small object { id, ... }. The id is the run id: your way to look the run up, cancel it, or show its status. It is not the task’s result, and you get it the moment the run is enqueued.
That instant return is what makes trigger fire-and-forget . The action gets its handle back in milliseconds and returns to the user, while the work runs later, off the request, in the task’s own world. The user sees “your export is running” at once; the export takes whatever time it needs with nobody watching a spinner.
If you do need the task’s result, triggerAndWait returns it, fully typed, but it is legal only inside another task’s body. A task can afford to pause for a child; a Server Action cannot, since waiting blocks the request past Vercel’s function time limit. Reaching for triggerAndWait from request code to get the answer now is the most common way people misuse the SDK.
Sometimes you can’t import the task, because it lives behind a service boundary or the caller shouldn’t pull in its dependencies. For that there’s a string form:
import { tasks } from '@trigger.dev/sdk';import type { exportCsv } from '@/trigger/export-csv';
await tasks.trigger<typeof exportCsv>('export-csv', { organizationId: orgId, since,});The typeof exportCsv generic on a type-only import recovers the full payload type without pulling the task’s runtime code into your bundle, so even the string form is type-checked. Reach for it only when you truly can’t import the instance.
The action calls exportCsv.trigger(payload), with the org id inside the payload.
The run is enqueued and the handle returns to the user now, in milliseconds.
A worker, a separate process with no request or session, picks up the run.
run() executes with only the payload to go on, which is why organizationId travels in it.
Status, logs, and payload show up in the dashboard with no extra wiring.
Here’s one decision that trips people up, because the instinct fights it.
A user clicks “Export,” and you’d like the Server Action’s response to already include the number of rows that were exported. Which trigger call hands you that count to return from the action?
return ok({ rows: (await exportCsv.triggerAndWait(payload)).output.rowCount });return ok({ rows: (await exportCsv.trigger(payload)).rowCount });return ok({ rows: (await exportCsv.run(payload)).rowCount });triggerAndWait would give you a result, but it’s legal only inside another task; from a Server Action it parks the request until the run completes and sails past the function time limit. trigger is fire-and-forget: it resolves to a handle ({ id, ... }), never the run’s output. And exportCsv.run(...) isn’t a callable method — run is the body Trigger.dev invokes on a worker, not something you call inline. Return the handle now; surface the count later.Per-tenant limits with queues and concurrencyKey
Section titled “Per-tenant limits with queues and concurrencyKey”By default, every task you trigger runs against your environment’s overall concurrency. That’s usually fine, but sometimes you need to cap it: no more than N runs of a task at once. That’s what a queue is for.
A queue caps how many runs of a task execute at the same time. The reason to cap is almost always downstream: an external API with a rate limit (Resend will throttle you), a third-party quota, or your own database connection pool that falls over past a certain number of concurrent writers. Set the limit to the smallest number that keeps the downstream happy. The limit lives on the queue, not in the run body, because back-pressure is a property of the queue, not of the work.
The first v4 break is here. In v3 you could pass a queue and its concurrency limit at the moment you triggered; v4 rejects that. The queue is declared at module scope instead:
import { queue, schemaTask } from '@trigger.dev/sdk';
const exportQueue = queue({ name: 'export', concurrencyLimit: 5,});
export const exportCsv = schemaTask({ id: 'export-csv', queue: exportQueue, schema: z.object({ organizationId: z.uuid(), since: z.iso.date() }), run: async (payload) => { // ... },});Treat a queue like a database table: you declare it in code, it gets migrated into existence when you deploy, and you never create it at call time. You wouldn’t CREATE TABLE in the middle of an insert, and you don’t declare a queue in the middle of a trigger. The v3 shape is everywhere online, and the failure is a runtime rejection, not a red squiggle: your code type-checks fine, then refuses at runtime.
A single queue caps the task globally, which creates a problem specific to multi-tenant SaaS. Give the export task one export queue with concurrencyLimit: 5, and every organization’s exports share those five slots. One enthusiastic customer kicks off a thousand exports, fills the queue, and every other org’s exports sit behind them. You built back-pressure for your database and accidentally built a way for one tenant to starve all the others.
The fix is a key, not a queue name. You keep the one predeclared export queue, and at trigger time you pass a concurrencyKey :
await exportCsv.trigger( { organizationId, since }, { concurrencyKey: organizationId },);concurrencyKey splits the queue’s limit into an independent lane per key value. With concurrencyLimit: 1 on the queue, each organization runs its exports one at a time while different organizations run in parallel. One extra option at the call site buys you sequential work within a tenant and parallel work across tenants. What varies per tenant is the key, not the queue’s name.
The left tab is what a search result or an AI completion will most likely hand you; the right is the v4 shape.
// What most search results and AI completions still produce:await exportCsv.trigger( { organizationId, since }, { queue: { name: `org-${organizationId}`, concurrencyLimit: 1 }, },);v4 rejects this. You can’t name a queue or set its concurrencyLimit at trigger time; the queue must be declared in code first.
await exportCsv.trigger( { organizationId, since }, { concurrencyKey: organizationId },);The v4 shape. The predeclared export queue is fixed; only the per-org key varies.
The sequence below runs three organizations, three exports each, the same concurrencyLimit: 1, with and without the key.
One shared queue: all nine runs funnel through a single lane, so org A’s three exports go first and B and C wait behind them. One busy tenant blocks everyone.
Same queue and limit, plus concurrencyKey: organizationId: the limit splits into one lane per org. A1, B1, and C1 run at once, and each org drains its own three sequentially. Sequential within a tenant, parallel across them.
One caveat: every lane still draws from your environment’s overall concurrency, so a tenant who spins up many distinct keys can still consume real capacity. Recent Trigger.dev versions bound this with a master-queue fairness mechanism you configure nothing for.
A teammate opens a PR with an export trigger. Review it the way you would for real; there’s a specific, plausible-looking mistake in here.
Review this PR before it merges. It triggers a per-org export and tries to keep one org's exports from blocking another's. Click any line to leave a review comment, then press Submit review.
'use server';
import { exportCsv } from '@/trigger/export-csv';import { requireOrgUser } from '@/lib/auth';
export const startExport = async (since: string) => { const { orgId } = await requireOrgUser(); await exportCsv.trigger( { organizationId: orgId, since }, { queue: { name: `org-${orgId}`, concurrencyLimit: 1 } }, );};This is the v3 shape, and v4 rejects it: you can neither name a brand-new queue nor set its concurrencyLimit at trigger time. The fix is two-part. Declare the queue once at module scope — const exportQueue = queue({ name: 'export', concurrencyLimit: 1 }) — and attach it to the task with queue: exportQueue. Then, here at the call site, drop the whole queue option and pass the per-tenant knob instead:
await exportCsv.trigger( { organizationId: orgId, since }, { concurrencyKey: orgId },);concurrencyKey splits the predeclared queue’s limit into one independent lane per org — sequential within a tenant, parallel across tenants — without ever naming a queue or setting a limit at trigger time.
The shape to recognize: any queue name or limit set at trigger time is a v3 leftover that deploys clean and then behaves wrong. In v4 the queue is fixed and declared in code, like a database table; the only per-tenant knob at the call site is concurrencyKey.
Scheduled tasks: static and dynamic
Section titled “Scheduled tasks: static and dynamic”Some background work runs on a clock rather than a user action: a nightly digest, a weekly rollup. Vercel Cron, seen earlier in this chapter, is the default home for a fixed schedule. Trigger.dev offers two forms that differ in when the schedule is created.
A static schedule lives in code and deploys with the task through schedules.task, giving you one global schedule. A dynamic schedule is created at runtime through schedules.create, one per tenant.
The two forms also write the cron expression differently, and the difference matters over daylight saving time. A plain string like cron: '0 9 * * *' is interpreted in UTC, which suits a sweep genuinely anchored to UTC. But for a wall-clock time (“9am for this customer”), UTC drifts twice a year when the clocks change, turning the 9am job into an 8am or 10am one. The object form { pattern, timezone } follows daylight saving and keeps the wall-clock time correct, using an IANA timezone . Default to the object form for any wall-clock schedule; use the plain string only for UTC-anchored work.
The dynamic form, schedules.create, backs “each organization picks its own digest time.” It writes the schedule differently from the static form: cron is always a plain string, and timezone is a separate top-level field rather than nested inside cron.
import { schedules } from '@trigger.dev/sdk';
export const nightlyDigest = schedules.task({ id: 'nightly-digest', cron: { pattern: '0 9 * * 1-5', timezone: 'America/New_York', }, run: async (payload) => { // payload.timestamp, payload.lastTimestamp, payload.upcoming },});One global schedule, declared in code. Deployed with the task like a Vercel Cron entry, but durable and observable for free.
import { schedules } from '@trigger.dev/sdk';
await schedules.create({ task: nightlyDigest.id, cron: '0 9 * * *', timezone: 'America/New_York', externalId: organizationId, deduplicationKey: `digest:${organizationId}`,});One schedule per tenant, created at runtime. externalId ties it to your org row, and deduplicationKey makes the create idempotent.
The dynamic form carries two extra fields. The externalId attaches your domain id: pass the organization id and you can later find, deactivate, or delete that org’s schedule with schedules.list({ externalId }), schedules.deactivate, and schedules.del. The deduplicationKey makes the create idempotent: without it, a retried “set my digest time” action would leave the org with two digests firing.
Here is the dynamic call inside the settings action where a customer sets their preferred time:
'use server';
import { schedules } from '@trigger.dev/sdk';import { nightlyDigest } from '@/trigger/nightly-digest';import { requireOrgUser } from '@/lib/auth';import { ok } from '@/lib/result';
export const setDigestTime = async (cron: string, timezone: string) => { const { orgId } = await requireOrgUser(); await schedules.create({ task: nightlyDigest.id, cron, timezone, externalId: orgId, deduplicationKey: `digest:${orgId}`, }); return ok({});};So when does a Trigger.dev schedule beat a Vercel Cron entry? Reach for it when the cadence must be dynamic or per-tenant, which Vercel Cron can’t express, or when the work needs Trigger.dev’s durability and retries anyway. A fixed daily UTC sweep that fits inside a Vercel function’s time budget stays on Vercel Cron.
Complete both schedule definitions. Watch the shape difference: one form nests timezone inside cron, the other keeps it top-level. Pick the right option from each dropdown, then press Check.
// Static: one global, DST-safe scheduleexport const nightlyDigest = schedules.task({ id: 'nightly-digest', cron: { ___: '0 9 * * 1-5', timezone: 'America/New_York', }, run: async (payload) => {},});
// Dynamic: one schedule per org, created at runtimeawait schedules.create({ task: nightlyDigest.id, cron: ___, ___: 'America/New_York', ___: organizationId,});Dashboard observability comes built in
Section titled “Dashboard observability comes built in”Every run is visible in the dashboard: its payload, its status as it moves through queued → executing → completed (or failed, or retrying), its start time, its duration, and every log line it emitted. The next lesson adds each retry and each wait to that view.
This is the payoff for the second platform. On Vercel Cron you get console.log and hope; here observability is included.
Two capabilities are worth knowing by name. metadata.set(...) is the live-progress channel: write a value like "47 of 200" from inside a run and watch it tick upward in the dashboard. When a run needs its own scoped resource, such as a dedicated database connection, v4 creates it in middleware through the locals API.
Worked example: notify-org-members end to end
Section titled “Worked example: notify-org-members end to end”This task assembles every piece of the lesson into one you could ship. When something noteworthy happens in an organization, such as an invoice getting paid or a member joining, email everyone in that org. It uses a schema for the payload, a queue for back-pressure, a concurrencyKey for per-tenant fairness, and tenancy carried in the payload.
import { queue, schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';import { tenantDb } from '@/db/tenant';import { sendEmail } from '@/lib/email';
const notificationsQueue = queue({ name: 'notifications', concurrencyLimit: 5 });
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', queue: notificationsQueue, schema: z.object({ organizationId: z.uuid(), eventType: z.string() }), run: async ({ organizationId, eventType }) => { const db = tenantDb(organizationId); const members = await db.query.orgMembers.findMany(); for (const member of members) { // TODO: make each send idempotent with a per-recipient key await sendEmail({ to: member.email, template: eventType }); } },});The queue is declared at module scope, in this same file. concurrencyLimit: 5 is the back-pressure that protects your email provider from a burst, set in code, not at trigger time.
import { queue, schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';import { tenantDb } from '@/db/tenant';import { sendEmail } from '@/lib/email';
const notificationsQueue = queue({ name: 'notifications', concurrencyLimit: 5 });
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', queue: notificationsQueue, schema: z.object({ organizationId: z.uuid(), eventType: z.string() }), run: async ({ organizationId, eventType }) => { const db = tenantDb(organizationId); const members = await db.query.orgMembers.findMany(); for (const member of members) { // TODO: make each send idempotent with a per-recipient key await sendEmail({ to: member.email, template: eventType }); } },});The task’s id and schema are its durable identity and its input contract. The payload carries organizationId and eventType, validated before run ever executes.
import { queue, schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';import { tenantDb } from '@/db/tenant';import { sendEmail } from '@/lib/email';
const notificationsQueue = queue({ name: 'notifications', concurrencyLimit: 5 });
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', queue: notificationsQueue, schema: z.object({ organizationId: z.uuid(), eventType: z.string() }), run: async ({ organizationId, eventType }) => { const db = tenantDb(organizationId); const members = await db.query.orgMembers.findMany(); for (const member of members) { // TODO: make each send idempotent with a per-recipient key await sendEmail({ to: member.email, template: eventType }); } },});The task has no session, so tenancy is re-derived from the payload: tenantDb(organizationId) scopes every query that follows to this org. Org context arrived as data, and here you turn it back into a scoped database.
import { queue, schemaTask } from '@trigger.dev/sdk';import { z } from 'zod';import { tenantDb } from '@/db/tenant';import { sendEmail } from '@/lib/email';
const notificationsQueue = queue({ name: 'notifications', concurrencyLimit: 5 });
export const notifyOrgMembers = schemaTask({ id: 'notify-org-members', queue: notificationsQueue, schema: z.object({ organizationId: z.uuid(), eventType: z.string() }), run: async ({ organizationId, eventType }) => { const db = tenantDb(organizationId); const members = await db.query.orgMembers.findMany(); for (const member of members) { // TODO: make each send idempotent with a per-recipient key await sendEmail({ to: member.email, template: eventType }); } },});The body reads the org’s members and emails each one. The loop is deliberately plain; making each send idempotent against retries is the next lesson’s job, flagged by the comment.
The other half is the trigger, the call from the Server Action helper that fires it.
const { orgId } = await requireOrgUser();const handle = await notifyOrgMembers.trigger( { organizationId: orgId, eventType: 'invoice.paid' }, { concurrencyKey: orgId },);Where to go deeper
Section titled “Where to go deeper”This topic moves fast, and the v3-to-v4 break you spent this lesson navigating is exactly the kind of thing that shifts again. The most reliable place to check the current shape of any of these APIs is the official documentation, which tracks the live version.
The canonical reference for task, schemaTask, triggering, and the ctx object — always on the current version.
Predeclared queues, concurrencyKey, and the fairness model, straight from the source.
The official migration guide for the exact v3→v4 break this lesson navigates: queues, concurrency, and lifecycle hooks.
schedules.task vs schedules.create, the cron and timezone forms, and managing per-tenant schedules by externalId.