Scheduled Tasks: Cron Jobs in the Stack
How to define and operate schedules.task() in this stack — with special attention to reading and writing Directus from inside a scheduled run, closing the loop back to Next.js, and attaching schedules to specific environments. For the full Trigger.dev schedule API, defer to the trigger-dev plugin's task-development skill.
Quick Decision: Is This the Right Tool?
| Need | Tool |
|---|---|
| Run code on a fixed cron (daily, hourly, every 15 min) | schedules.task() (this skill) |
| Run code in response to a user action | task() + tasks.trigger() (see background-tasks) |
| Run code when a Directus item changes | Directus Flow → webhook → tasks.trigger() (see directus-to-trigger) |
| Fire an HTTP webhook on a schedule with no logic | Directus Flows (Schedule trigger) — simpler, no task needed |
Use schedules.task() when you need durable execution, retries, observability, and typed code that can talk to Directus and other services.
Defining a Schedule
Place schedule definitions in /trigger/schedules/ or /trigger/ alongside regular tasks. Trigger.dev discovers them automatically.
// trigger/schedules/daily-digest.ts
import { schedules, logger } from '@trigger.dev/sdk/v3';
import { createDirectus, rest, staticToken, readItems } from '@directus/sdk';
import type { Schema } from '@/types/directus';
export const dailyDigestTask = schedules.task({
id: 'daily-digest',
// Run every day at 08:00 UTC
cron: {
pattern: '0 8 * * *',
timezone: 'UTC',
},
maxDuration: 600, // 10 minutes
run: async (payload, { ctx }) => {
// payload.timestamp — when the schedule fired
// payload.lastTimestamp — previous run's timestamp
// payload.externalId — if this schedule was created dynamically with an external ID
// payload.upcoming — array of next few scheduled timestamps
logger.log('Daily digest starting', {
runAt: payload.timestamp,
previousRun: payload.lastTimestamp,
});
const directus = createDirectus<Schema>(process.env.NEXT_PUBLIC_DIRECTUS_URL!)
.with(staticToken(process.env.DIRECTUS_ADMIN_TOKEN!))
.with(rest({ cache: 'no-store' }));
// ... do the work ...
return { digestSent: true };
},
});
Cron Patterns
Standard 5-field cron (minute, hour, day-of-month, month, day-of-week):
| Pattern | Meaning |
|---|---|
*/15 * * * * |
Every 15 minutes |
0 * * * * |
Every hour on the hour |
0 8 * * * |
Daily at 08:00 |
0 8 * * 1-5 |
Weekdays at 08:00 |
0 0 1 * * |
First day of every month, midnight |
0 2 * * 0 |
Every Sunday at 02:00 |
Minimum resolution: 1 minute. Trigger.dev does not allow sub-minute schedules — don't use * * * * * (every minute) unless you genuinely need it, and never try */30 * * * * * (every 30 seconds).
Timezone-Aware Scheduling
cron: {
pattern: '0 9 * * 1-5',
timezone: 'America/New_York', // runs at 09:00 NY time, adjusts for DST
},
Use timezones when the schedule needs to match a business calendar (market open, store hours, customer-local daily digest). Use UTC for system jobs where timing doesn't depend on humans.
Reading from Directus in a Schedule
Build a server-side Directus client inside the task's run function. Do NOT import the singleton from lib/directus.ts — that module has import 'server-only' which throws outside Next.js.
run: async (payload, { ctx }) => {
const directus = createDirectus<Schema>(process.env.NEXT_PUBLIC_DIRECTUS_URL!)
.with(staticToken(process.env.DIRECTUS_ADMIN_TOKEN!))
.with(rest({ cache: 'no-store' }));
// Read items created since the last run
const since = payload.lastTimestamp?.toISOString() ?? new Date(0).toISOString();
const newItems = await directus.request(
readItems('orders', {
filter: { date_created: { _gte: since } },
fields: ['id', 'customer_email', 'total'],
sort: ['-date_created'],
limit: 1000,
})
);
logger.log('Found new items since last run', { count: newItems.length, since });
// ... process them ...
},
Using payload.lastTimestamp gives you an incremental "what changed since last run" window for free — no state table needed on your side.
Writing Back to Directus
await directus.request(
updateItem('orders', orderId, {
digest_sent_at: new Date().toISOString(),
digest_run_id: ctx.run.id, // store the Trigger run ID for audit
})
);
If Directus is down, the request throws. Trigger's retry mechanism will re-run the task per the retry config — you don't need to add a try/catch unless you want to continue on partial failure. See "Idempotency" below for safe retries.
Idempotency for Scheduled Runs
Scheduled tasks can be retried (automatic retries on failure, or manual re-runs from the dashboard). Any Directus writes need to be safe under repetition.
Pattern 1: Check-then-act
const [existing] = await directus.request(
readItems('digests', {
filter: {
run_timestamp: { _eq: payload.timestamp.toISOString() },
},
limit: 1,
})
);
if (existing) {
logger.log('Digest for this timestamp already sent — skipping', { runId: existing.id });
return { skipped: true };
}
// ... proceed to create the digest ...
Pattern 2: Upsert with a deterministic key
Use a composite natural key: (digest_type, date). Try to find it; if present, update; else create. Retry-safe.
Pattern 3: Idempotency on inner task calls
If the schedule fans out to other tasks, pass idempotencyKey to each tasks.trigger():
await tasks.trigger<typeof enrichOrderTask>(
'enrich-order',
{ orderId },
{ idempotencyKey: `enrich-order-${orderId}-${payload.timestamp.toISOString()}` }
);
Attaching Schedules to Environments
Schedules are NOT automatically active after deploy. This is a deliberate safety — you don't want a production cron to start firing the first time a dev pushes new code.
Attach via the dashboard: Trigger.dev → Schedules → {task-id} → Attach → select environment (dev / staging / prod).
Or via CLI:
# After deploy
npx trigger.dev@latest schedules:attach daily-digest --env prod
You can also attach to a specific timezone per environment, e.g. run the same task on UTC in dev and on America/New_York in prod.
Declarative vs Dynamic Schedules
| Declarative (what this skill shows) | Dynamic |
|---|---|
Schedule is defined in code with schedules.task({ cron }) |
Schedule is created at runtime via schedules.create() |
| Requires a deploy to change the cron | Can be changed via API call / user action |
| Best for system jobs (daily digest, hourly sync) | Best for per-tenant or user-configured schedules |
| Attached once in the dashboard | Created on demand from a Server Action |
For a user-configured schedule (e.g. "send me a weekly report at a time I choose"), use schedules.create() inside a Server Action:
'use server';
import { schedules } from '@trigger.dev/sdk/v3';
export async function scheduleWeeklyReport(userId: string, hour: number, timezone: string) {
const schedule = await schedules.create({
task: 'send-user-report',
cron: `0 ${hour} * * 1`, // every Monday at :00 in the given hour
timezone,
externalId: `user-${userId}`, // your stable identifier
deduplicationKey: `weekly-${userId}`, // prevents duplicate creation
});
// Store schedule.id in Directus so you can delete/update it later
return schedule;
}
Use schedules.del(scheduleId) when the user disables it, and schedules.deactivate(scheduleId) to pause without deleting.
Close-the-Loop Revalidation
If the schedule updates Directus data that a Next.js page displays, POST to /api/revalidate at the end of the run so users see fresh content:
// at the end of run()
await fetch(
`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate?secret=${process.env.REVALIDATION_SECRET}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collection: 'orders' }),
}
);
Without this, the page stays at its last ISR cache until the revalidate interval expires or a user navigates to an uncached variant.
Local Testing
Run npx trigger.dev@latest dev in one terminal. The dev server registers all schedule tasks BUT does not automatically fire them on the real cron — that would be unpredictable during development.
Test-run from the dashboard:
- Open the Trigger.dev dashboard → Tasks →
daily-digest - Click "Test" → provide a fake payload:
{ timestamp: new Date(), lastTimestamp: new Date(Date.now() - 86400000) } - The run executes against local env vars (from
.env.localproxied through the dev server)
For CI, you can also test-run via MCP: trigger-dev:run_task with a synthetic payload.
Production Operation
- Enable/disable from the dashboard per environment
- Manual re-run any past execution (e.g. to backfill after an outage)
- Inspect logs for each run —
logger.log()output is searchable - Alerts: configure the Trigger.dev dashboard to notify on task failure (email, Slack webhook)
- Versioning: each deploy creates a new task version — in-flight runs finish on the old version, new runs use the new version
Anti-Patterns
| Don't | Do |
|---|---|
import directus from '@/lib/directus' inside a task |
Build a fresh client inside run() — lib/directus.ts has 'server-only' which breaks outside Next.js |
Sub-minute crons (* * * * * minus a few) |
Minimum 1 minute resolution — fan out from a 1-min task if you truly need faster |
| Run the same cron in dev + staging + prod attached simultaneously | Attach only to the environments you want firing; dev should fire on-demand via test-run |
| Blind writes on retry (no idempotency) | Use deterministic keys + upsert, or check-then-act |
| Skip revalidation at the end of the task | Close the loop — POST to /api/revalidate so Next.js ISR reflects the new data |
Hardcode cron: '0 8 * * *' without a timezone |
Always set cron.timezone explicitly (UTC or a business zone) — ambiguous default drifts on DST |