Skip to content
Chapter 67Lesson 1

Project overview

What you'll build this chapter, a durable paginated CSV export on Trigger.dev, and how to set it up locally.

Over the next three lessons you’ll build a durable, paginated CSV export of an organization’s invoices, fired from a Server Action on Trigger.dev v4. The user clicks Export invoices in the inspector and the Server Action returns in milliseconds, while a background run counts the org’s invoices, streams them out page by page, emails the requester a download link, and writes an audit record. The run survives a worker being killed and replayed, serializes one org’s exports while running other orgs in parallel, sends the email exactly once, and reports progress live to the inspector.

The inspector after a completed export: the run panel with its progress bar full, the rendered download link, the `export.invoices.completed` row in the audit-log tail, and the `ExportReadyEmail` delivered to the requester's inbox.

These five skills make up the shape every durable background job reuses:

  • Modeling a long-running job as fire-and-forget. A Server Action triggers a Trigger.dev schemaTask and returns at once, so the user never waits on the export.
  • Designing for durability. Checkpoints at step boundaries let a worker that dies mid-run resume from the last completed step instead of starting over.
  • Reasoning about multi-tenant back-pressure. One predeclared queue plus a per-tenant concurrencyKey serializes each org’s exports without letting one org starve the others.
  • Guarding side effects with idempotency keys. Keys at two scopes prevent both a duplicate trigger and a re-sent email.
  • Streaming live progress to a watching client. The worker writes page-by-page progress to run.metadata, which the inspector polls to drive the progress bar.
AppTrigger.dev workerInspectorServer ComponentstartExportServer ActionexportsrowexportInvoicesparent task· export queue· concurrencyKey: orgId· limit 1paginatePagechild tasksendExportEmailchild taskexports row+ audit log calls writes triggerAndWait,one per pagetriggerAndWaitone tenanttransactiontasks.triggerfire-and-forget,returns runId poll run state 1s(status, metadata)
The app fires the export and polls its state from outside the boundary; the Trigger.dev worker runs it.

The inspector, a Server Component, calls the startExport Server Action. startExport fires exportInvoices fire-and-forget with tasks.trigger, writes a row in the exports table, and returns the runId without waiting. 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 per page through triggerAndWait and accumulating CSV. Then it awaits sendExportEmail, updates the exports row, and writes the audit log in a single tenant transaction. Meanwhile the inspector polls GET /api/exports/[runId], which reads run state from the Trigger.dev REST API: status, attemptCount, and the pagesDone / pagesTotal progress carried on metadata. The app fires and polls; the worker executes.

The starter is a working Better Auth + Drizzle + Trigger.dev app with one unfinished slice: the export. Four files ship as stubs; they are the only files you write all chapter, and everything else is provided, introduced in the lesson that first uses it.

  • docker-compose.yml postgres:18 for local dev
  • trigger.config.ts Trigger.dev v4 config: dirs: ['./trigger'], retries
  • .env.example copy to .env (see Local setup)
  • package.json db:migrate, db:seed, trigger:dev, dev, test:lesson
  • Directorytrigger/ task folder registered by dirs: ['./trigger']
    • export-invoices.ts the parent task — you write this
    • paginate-page.ts child task: one page → CSV fragment — you write this
    • send-export-email.ts child task: render + send to the recipient — you write this
  • Directoryscripts/
    • seed.ts 4 orgs (3 with invoices, one empty), 6 users, fixed ids
  • Directorysrc/
    • env.ts T3 env boundary, validates every server + client var
    • Directorydb/
      • index.ts
      • schema.ts invoices, exports, emailSuppressions tables
      • schema/auth.ts Better Auth generated schema (user, organization, member, …)
      • audit.ts auditLogs table + RLS policies
      • audit-log.ts logAudit()
      • tenant.ts tenantDb() facade + transaction helper
      • Directoryqueries/
        • invoices.ts listInvoices() (cursor) + countInvoices()
        • audit.ts
    • Directorylib/
      • result.ts Result<T>, ok(), err()
      • auth/authed-action.ts authedAction() factory
      • email.ts sendEmail() with suppression check
      • suppressions.ts
      • trigger-client.ts retrieveRun() + listRunsForOrg() over the Trigger.dev REST API
      • Directoryexports/
        • to-csv.ts rowsToCsv(): RFC-4180 string
        • day-bucket.ts dayBucket()'YYYY-MM-DD' (UTC)
        • errors.ts ExportError class
        • start.ts the startExport action — you write this
    • Directoryemails/
      • ExportReadyEmail.tsx React Email template
    • Directoryapp/
      • Directory(protected)/inspector/
        • page.tsx the inspector: export controls, run panel, audit tail
        • _data.ts getInspectorContext(), recentExports(), latestExport()
        • actions.ts dev-only simulate / reset / switch-identity helpers
        • Directory_components/ run console, run panel (1s poller), debug controls
      • api/exports/[runId]/route.ts GET → reads run state, returns it as JSON

The one piece to know now is the exports table in src/db/schema.ts. It holds id, organizationId, requestedBy, status, runId, rowCount, idempotencyKey, dayBucket, pagesDone, pagesTotal, downloadUrl, requestedAt, and completedAt, with a unique index on (organizationId, requestedBy, dayBucket) for the app’s own audit and deduplication. Trigger.dev’s run record is the operational source of truth; the exports row is the app’s reference to it.

Three lessons build the export one slice at a time.

Lesson 2 — The task boundary

Confirm the shipped exportInvoices boundary (Zod payload, 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 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: update the exports row and write the audit log in one transaction.

Local development needs two terminals: the Trigger.dev worker that runs tasks, and the Next.js dev server. Without the worker, runs queue forever. Create the Trigger.dev cloud project first, since npx trigger.dev@latest init links your folder to it.

  1. Get the starter from the project repository, under Chapter 067/start/. Its four task files are stubs.

  2. Install dependencies:

    Terminal window
    pnpm install

    The repo pins pnpm via only-allow, so npm and yarn refuse.

  3. Copy the env template, then start Postgres:

    Terminal window
    cp .env.example .env
    docker compose up -d

    docker-compose.yml ships postgres:18, healthy within seconds.

  4. Apply the schema and seed the data:

    Terminal window
    pnpm db:migrate && pnpm db:seed

    The seed creates four organizations (three with 200–240 invoices each, plus an empty org_empty) and six users with fixed ids. It truncates and re-inserts, so re-running is safe, and never touches the Trigger.dev cloud.

  5. Create a Trigger.dev account, then link your folder to a cloud project:

    Terminal window
    npx trigger.dev@latest init

    The flow writes trigger.config.ts defaults and registers trigger/ via dirs: ['./trigger']. The starter ships a matching config, so you confirm it, not generate it.

  6. Paste TRIGGER_SECRET_KEY and TRIGGER_PROJECT_REF into .env, both from the dashboard. The secret key is per-environment, so use the dev key.

  7. Start the worker in one terminal:

    Terminal window
    pnpm trigger:dev

    It prints the dashboard URL and shows Waiting for tasks.

  8. Start the app in a second terminal:

    Terminal window
    pnpm dev

    Visit /inspector, behind the auth guard, to see the export controls.

Three environment variables are new this chapter; the rest carry over and are already in .env.example.

VariablePurposeHow to obtain
TRIGGER_SECRET_KEYAuthenticates the worker and the REST reads (validated startsWith 'tr_').Trigger.dev dashboard, per environment — use the dev key.
TRIGGER_PROJECT_REFIdentifies the linked project (validated startsWith 'proj_').Trigger.dev dashboard.
APP_URLThe app’s base URL, used to build the export download link base.http://localhost:3000 locally.

The .env.example ships placeholder tr_… and proj_… values that pass validation, so next build succeeds without reaching the Trigger.dev cloud; you need the real values only for the live run loop.

You’re set up when the inspector shows the export controls and the worker reads Waiting for tasks. Click Export invoices now and you get err('internal', 'Not implemented') from startExport, which is where the next lesson begins.