Background jobs: use defineJob
Payload-driven QStash work goes through defineJob. Do not add a new HTTP route under /api/jobs — every job is executed by the existing apps/web/app/api/jobs/process/[jobName]/route.ts.
Keep using withCron for Vercel GET schedules and for cron scanners that have no job envelope. See the cron-use-with-cron skill for those.
File layout
apps/web/lib/jobs/
├── index.ts # defineJob — do not edit unless changing the framework
├── registry.ts # jobLoaders — register every new job here
├── send-jobs.ts # envelope + QStash request builder
└── handlers/
└── {name}-job.ts # one file per job
1. Create the handler
Add apps/web/lib/jobs/handlers/{name}-job.ts. The name must be kebab-case and end in -job (enforced by jobNameSchema: /^[a-z][a-z0-9]*(-[a-z0-9]+)*-job$/).
import * as z from "zod/v4";
import { defineJob } from "../index";
const inputSchema = z.object({
programId: z.string(),
partnerId: z.string(),
});
export const unbanPartnerJob = defineJob({
name: "unban-partner-job",
schema: inputSchema,
defaults: {
retries: 3, // optional; QStash retries on 5xx
// queue: "unban-partner", // optional; named QStash queue
// flowControl: { key: "unban-partner", parallelism: 20 },
},
async handle(input) {
// skip (permanent / not found) → return (process route returns 2xx, QStash does not retry)
// transient failure → throw (process route returns 500, QStash retries)
},
});
Export the const as camelCase {name}Job matching the kebab name.
Reference handlers:
- Simple skip/work:
unban-partner-job.ts,create-tremendous-campaign-job.ts - Self-pagination:
folder-deleted-job.ts,domain-deleted-job.ts,partner-search-sync-job.ts defaults.flowControl:partner-search-sync-job.ts
2. Register it
Add a static import() in apps/web/lib/jobs/registry.ts jobLoaders. The object key must equal defineJob({ name }). Webpack code-splits each handler.
"unban-partner-job": () =>
import("./handlers/unban-partner-job").then((m) => m.unbanPartnerJob),
Do not register by editing the process route. loadJob throws if job.name !== the registry key.
3. Dispatch from call sites
Import the handler (not qstash) and call dispatch / dispatchBatch:
import { unbanPartnerJob } from "@/lib/jobs/handlers/unban-partner-job";
await unbanPartnerJob.dispatch(
{ workspaceId, programId, partnerId },
{ label: partnerId },
);
await folderDeletedJob.dispatchBatch(
folderIds.map((folderId) => ({ folderId })),
({ folderId }) => ({ label: folderId }),
);
Per-dispatch options merge over defaults: delay, notBefore, deduplicationId, retries, queue, flowControl, label.
Failed QStash publish is persisted to the jobs outbox automatically — do not catch-and-swallow unless even the outbox persist failing must not fail the source mutation (see queue-partner-search-sync.ts).
Self-pagination: call theJob.dispatch(nextPayload, { delay: 1 }) from handle (see folder-deleted-job, partner-search-sync-job).
Do not call job.execute from app code. That is only for the process route (and cron drain shims below).
4. Convert an existing cron worker
- Move the
withCronbody intohandle. ReplacelogAndRespond("skip…")withconsole.info/console.error+return. - Register + switch every
qstash.publishJSON/enqueueJSON/enqueueBatchJobstargeting that cron URL tojob.dispatch/dispatchBatch. - Keep a thin POST shim at the old
/api/cron/...URL that parses the old body (not the job envelope) and callsjob.execute(payload). That drains in-flight QStash messages. Do not add a shim for brand-new jobs. - Delete cron-only helpers that moved with the handler.
Handle semantics
| Outcome | What to do | HTTP from process route | QStash |
|---|---|---|---|
| Work done | return |
200 | stop |
| Skip (not found, already done, env not configured) | console.* + return |
200 | stop |
| Bad payload | throw ZodError (schema.parse) |
200 | stop (non-retryable) |
| Transient failure | throw |
500 | retry |
Unknown job names and invalid envelopes also return 2xx so QStash does not retry forever.
Do not use defineJob for
- Vercel GET crons in
apps/web/vercel.json - Scanners that only fan out work (the worker they enqueue can be a job)
- Importers that republish continuation state to the same URL
/api/cron/queue/retry(job replay infrastructure)- Path-param identity (
/api/cron/links/[linkId]/…) unless the id moves into the payload - Outbound webhook forwarding and postbacks
Do not
- Add a new
/api/jobs/...route or a new/api/cron/...POST worker for payload-driven work. - Call
qstash.publishJSON/enqueueJSON/enqueueBatchJobswith/api/jobs/process/...— usedispatch. - Register jobs with a dynamic
import()that webpack cannot statically analyze, or a key that differs fromdefineJob({ name }). - Name a job without the
-jobsuffix. - Use
job.executeat dispatch call sites. - Run
pnpm buildafter adding a job.