Lesson 2 — The task boundary
Confirm the shipped exportInvoices task boundary (its Zod payload and predeclared queue), then write the startExport action that fires it with concurrencyKey: orgId and a daily idempotency key.
Over the next three lessons you’ll build a durable, paginated CSV export of an organization’s invoices, fired from a Server Action and built on Trigger.dev v4. From the user’s seat it’s one button: they click Export invoices in the inspector, and a background run counts the org’s invoices, streams them out a page at a time, emails the requester a download link, and writes an audit record — all while the request that started it returned in milliseconds. Behind that button the run survives a worker being killed and replayed, serializes per organization while running different orgs in parallel, sends the email exactly once even if the parent retries, and reports its progress live to the panel watching it. The inspector and the Trigger.dev dashboard let you watch the whole thing happen.
This project is the canonical shape every durable background job in a SaaS app will copy. By the end you’ll have practiced:
schemaTask and returns immediately — the user never waits on the export to finish.concurrencyKey lane keeps each org’s exports serialized without letting one org starve the others.run.metadata, and the inspector polls it to drive the progress bar.The export crosses a boundary that prose carries badly: the app fires the job and then polls it from the outside, while the actual work runs in the Trigger.dev worker. Here’s the whole path, end to end.
Read it left to right. The inspector, a Server Component, calls the startExport Server Action. startExport fires exportInvoices fire-and-forget with tasks.trigger, records a row in the exports table, and returns the runId straight away — it does not wait for the export. Over in the worker, exportInvoices runs on the predeclared export queue, in this org’s concurrencyKey lane at concurrency 1. It counts the pages, then loops, awaiting one paginatePage child run per page through triggerAndWait and accumulating CSV as it goes. When the loop finishes it awaits the sendExportEmail child, then updates the exports row and writes the audit log in a single tenant transaction. Meanwhile the inspector polls GET /api/exports/[runId], which reads the run’s state structurally from the Trigger.dev REST API — status, attemptCount, and the pagesDone / pagesTotal progress the task carries on metadata. The app fires and polls; the worker executes. That split is the whole design.
The starter is a complete Better Auth + Drizzle + Trigger.dev app, not a bare scaffold. The export is the only unfinished slice. Four files ship as stubs — they’re the only files you’ll write across the whole chapter — and everything else is provided and explained in the lesson that first touches it.
postgres:18 for local devdirs: ['./trigger'], retries.env (see Setup)db:migrate, db:seed, trigger:dev, dev, test:lessondirs: ['./trigger']invoices, exports, emailSuppressions tablesuser, organization, member, …)auditLogs table + RLS policieslogAudit()tenantDb() facade + transaction helperlistInvoices() (cursor) + countInvoices()Result<T>, ok(), err()authedAction() factorysendEmail() with suppression checkretrieveRun() + listRunsForOrg() over the Trigger.dev REST APIrowsToCsv(): RFC-4180 stringdayBucket() → 'YYYY-MM-DD' (UTC)ExportError classstartExport action — you write thisgetInspectorContext(), recentExports(), latestExport()GET → reads run state, returns it as JSONA handful of provided pieces do most of the heavy lifting, and each gets a proper introduction in the lesson that first leans on it: src/lib/email.ts for the Resend send, src/db/queries/invoices.ts for the listInvoices and countInvoices reads, src/lib/exports/to-csv.ts for turning rows into RFC-4180 CSV, src/emails/ExportReadyEmail.tsx for the template, src/lib/trigger-client.ts for reading run state over the Trigger.dev REST API, the inspector page that fires and watches runs, and the exports table in src/db/schema.ts. That table carries id, organizationId, requestedBy, status, runId, rowCount, idempotencyKey, dayBucket, pagesDone, pagesTotal, downloadUrl, requestedAt, and completedAt, with a unique index on (organizationId, requestedBy, dayBucket). It exists for the app’s own audit and deduplication; Trigger.dev’s run record is the operational source of truth, and the exports row is the app’s reference to it.
Here’s how the three implementation lessons build the export, one slice at a time.
Lesson 2 — The task boundary
Confirm the shipped exportInvoices task boundary (its Zod payload and predeclared queue), then write the startExport action that fires it with concurrencyKey: orgId and a daily idempotency key.
Lesson 3 — One checkpoint per page
Spawn each page as a durable paginatePage child run, drive the progress bar through metadata, and abort permanently on an empty resultset.
Lesson 4 — Send the email, write the audit log
Add the sendExportEmail child guarded by a per-run key, then close the run by updating the exports row and writing the audit log in one transaction.
Local development for this project needs two terminals: the Trigger.dev worker and the Next.js dev server. The worker is what actually executes the tasks, so without it your triggered runs would queue forever. One extra wrinkle compared to earlier projects: the Trigger.dev cloud project has to exist first, because npx trigger.dev@latest init links your local folder to it. Work through these in order.
Get the starter codebase from the project repository, under Chapter 067/start/. You’ll find the file tree above, with the four task files shipped as stubs and everything else complete.
Install dependencies:
pnpm installThe repo pins pnpm through only-allow, so npm or yarn will refuse. Expect a clean install.
Copy the env template, fill in the secrets you already have, then start Postgres:
cp .env.example .envdocker compose up -ddocker-compose.yml ships postgres:18. The container should report healthy within a few seconds.
Apply the schema and seed the data:
pnpm db:migrate && pnpm db:seedThe seed creates four organizations — three with 200–240 invoices each, plus an empty org_empty — and six users with fixed, readable ids. It truncates and re-inserts deterministically, so re-running it is safe. Trigger.dev’s run history lives in the cloud and is separate, so the seed never touches it.
Create a Trigger.dev account, then link your project folder to a cloud project:
npx trigger.dev@latest initThe interactive flow writes the trigger.config.ts defaults, registers the root-level trigger/ folder via dirs: ['./trigger'], and prints your project ref. The starter already ships a trigger.config.ts matching what init produces, so you’re confirming it, not generating it from scratch.
Paste TRIGGER_SECRET_KEY and TRIGGER_PROJECT_REF into .env. Both come from the Trigger.dev dashboard for the project you just created. The secret key is per-environment (dev / staging / production) — use the dev key.
Start the worker in one terminal:
pnpm trigger:devIt prints the dashboard URL and shows Waiting for tasks. Open that dashboard once to confirm the project is linked and shows zero runs.
Start the app in a second terminal:
pnpm devVisit /inspector (it sits behind the auth guard) and you should see the export controls.
Three of the environment variables are new this chapter; the rest carry over from earlier projects and are already in .env.example.
| Variable | Purpose | How to obtain |
| --- | --- | --- |
| TRIGGER_SECRET_KEY | Authenticates the worker and the REST reads (validated startsWith 'tr_'). | Trigger.dev dashboard, per environment — use the dev key. |
| TRIGGER_PROJECT_REF | Identifies the linked project (validated startsWith 'proj_'). | Trigger.dev dashboard. |
| APP_URL | The app’s base URL, used to build the export download link base. | http://localhost:3000 locally. |
The carried-over variables are DATABASE_URL (and DATABASE_URL_UNPOOLED), RESEND_API_KEY, EMAIL_FROM / EMAIL_REPLY_TO, BETTER_AUTH_SECRET / BETTER_AUTH_URL, INVITATION_SIGNING_SECRET, and the public NEXT_PUBLIC_APP_NAME / NEXT_PUBLIC_APP_URL. One convenience worth knowing: the starter’s .env.example already contains placeholder tr_… and proj_… values that satisfy the env validation, so next build passes without ever reaching the Trigger.dev cloud — you only need the real values for the live run loop.
You’re set up when the inspector shows the export controls and the worker terminal reads Waiting for tasks. Click Export invoices now and you’ll get an error — startExport still returns err('internal', 'Not implemented'). That error is exactly where the next lesson begins.