Background Tasks: Next.js → Trigger.dev
How to delegate slow, flaky, or long-running work from a Next.js App Router application to durable Trigger.dev tasks. This skill covers the integration seam only — for task SDK depth (retries, queues, waits, metadata, Zod), defer to the trigger-dev plugin's task-development skill.
When to Offload
Run work inline in a Server Action or Server Component when:
- It finishes in under 500ms
- It's deterministic and rarely fails
- The user is actively waiting for the result
Delegate to a Trigger.dev task when any of these apply:
| Signal | Why |
|---|---|
| Call to an LLM or AI API | Unpredictable latency (2–60s), rate limits, retries needed |
| Image / video / PDF processing | CPU-heavy, likely >5s |
| Third-party API call that can fail (payment, webhook, email) | Retries + observability are free from Trigger |
| Webhook receiver that does non-trivial work | Return 200 fast so the sender doesn't retry; do the real work async |
| Bulk operation over many records | Batch processing + fan-out |
| Work that must outlive the HTTP request (serverless platforms often have 10–60s limits) | Trigger runs independently |
| Needs idempotency / retries / schedules / observability | Trigger provides all of these |
Triggering from a Route Handler
The route handler receives an HTTP request, decides to offload, calls tasks.trigger(), and returns the run handle immediately.
// app/api/process-order/route.ts
import { tasks } from '@trigger.dev/sdk/v3';
import { NextRequest, NextResponse } from 'next/server';
import type { processOrderTask } from '@/trigger/process-order';
// CRITICAL: prevent Next.js from trying to statically generate this route.
// Without this, CI builds fail because TRIGGER_SECRET_KEY isn't available
// at static-generation time — even though the route is obviously dynamic.
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
const body = await request.json();
const handle = await tasks.trigger<typeof processOrderTask>(
'process-order',
{ orderId: body.orderId },
{ idempotencyKey: `order-${body.orderId}` }
);
return NextResponse.json({
ok: true,
runId: handle.id,
publicAccessToken: handle.publicAccessToken,
});
}
Why force-dynamic is mandatory
Next.js tries to pre-render every route at build time. When it hits a route that calls tasks.trigger(), the SDK tries to initialize during the static export — which fails because TRIGGER_SECRET_KEY isn't set in the CI build environment. Adding export const dynamic = 'force-dynamic' tells Next.js to always render this route at request time, which is correct anyway since every call produces a different run ID.
Alternative: export const runtime = 'nodejs' forces Node runtime but does NOT prevent static generation — use force-dynamic instead.
Triggering from a Server Action
Server Actions ('use server') are a cleaner way to offload from a form submission or button click. No route handler boilerplate.
// app/orders/actions.ts
'use server';
import { tasks } from '@trigger.dev/sdk/v3';
import { revalidatePath } from 'next/cache';
import type { processOrderTask } from '@/trigger/process-order';
export async function submitOrder(orderId: string) {
const handle = await tasks.trigger<typeof processOrderTask>(
'process-order',
{ orderId },
{ idempotencyKey: `order-${orderId}` }
);
// Optionally: persist the run handle to Directus / DB so the UI can poll it
// await directus.request(updateItem('orders', orderId, { last_run_id: handle.id }));
revalidatePath('/orders');
return { runId: handle.id, publicAccessToken: handle.publicAccessToken };
}
Call from a Client Component:
'use client';
import { submitOrder } from './actions';
export function OrderButton({ orderId }: { orderId: string }) {
// CORRECT: wrap in an arrow function — React passes the event as first arg,
// which would be passed to submitOrder if you did `onClick={submitOrder}`.
return (
<button => submitOrder(orderId)}>
Submit Order
</button>
);
}
Event handler gotcha — onClick={submitOrder} passes the React event object to submitOrder(orderId) as the first arg. Always wrap: onClick={() => submitOrder(orderId)}.
Triggering and Polling
If the caller needs the task result before returning, use triggerAndPoll (or triggerAndWait from inside another task). Avoid this in Server Actions — the HTTP request sits open, defeating the point of offloading.
// Prefer this only for internal CLI scripts or admin tools, not user-facing routes.
const result = await tasks.triggerAndPoll<typeof processOrderTask>(
'process-order',
{ orderId },
{ pollIntervalMs: 2000 }
);
For user-facing routes, return the run handle immediately and subscribe via the realtime hook (see below) or poll from the client.
Fan-Out: Many Tasks at Once
For webhooks that affect multiple items, fan out with Promise.all:
const handles = await Promise.all(
itemIds.map((id) =>
tasks.trigger<typeof enrichItemTask>(
'enrich-item',
{ itemId: id },
{ idempotencyKey: `enrich-item-${id}` }
)
)
);
Promise.all is safe with tasks.trigger() because it just enqueues runs. It is NOT safe inside a task with triggerAndWait() — use batchTriggerAndWait there instead (see trigger-dev plugin).
Importing Task Types
Import the task only as a type (not a value) from the triggering side. This keeps the task code out of the Next.js bundle.
import type { processOrderTask } from '@/trigger/process-order';
// ✅ Type-only import — no runtime cost, no bundling of task code
// ❌ DON'T do this:
// import { processOrderTask } from '@/trigger/process-order';
// This pulls the task (and all its dependencies — OpenAI, Prisma, etc.) into the Next.js bundle
tasks.trigger<typeof processOrderTask>('process-order', ...) uses TypeScript's type inference to get the payload/return types without bundling the runtime code.
Realtime Status in Client Components
After triggering, subscribe to the run with useRealtimeRun from @trigger.dev/react-hooks to show live progress.
First, the server-side code returns a publicAccessToken (scoped to a single run):
// in your Server Action or route handler
const handle = await tasks.trigger<typeof processOrderTask>('process-order', { orderId });
return { runId: handle.id, publicAccessToken: handle.publicAccessToken };
Then the Client Component subscribes:
'use client';
import { TriggerProvider, useRealtimeRun } from '@trigger.dev/react-hooks';
export function OrderStatusProvider({
runId,
publicAccessToken,
children,
}: {
runId: string;
publicAccessToken: string;
children: React.ReactNode;
}) {
return (
<TriggerProvider
accessToken={publicAccessToken}
apiUrl={process.env.NEXT_PUBLIC_TRIGGER_API_URL!}
>
{children}
</TriggerProvider>
);
}
export function OrderStatus({ runId }: { runId: string }) {
const { run, error } = useRealtimeRun(runId);
if (error) return <p>Error: {error.message}</p>;
if (!run) return <p>Starting…</p>;
return (
<div>
<p>Status: {run.status}</p>
<p>Attempt: {run.attemptCount ?? 1}</p>
</div>
);
}
The public access token is scoped to ONE run and expires when the run finishes — safe to pass to the browser. Do NOT pass TRIGGER_SECRET_KEY to the browser.
Add NEXT_PUBLIC_TRIGGER_API_URL=https://trigger.your-domain.com to .env.local so the Provider can connect from the browser.
Closing the Loop: Task → Next.js Revalidation
When a task finishes mutating data, it should tell Next.js to invalidate the cached routes that display that data. Two options:
Option A: Task POSTs to /api/revalidate
The task itself fires a request to a Next.js API route that calls revalidatePath().
// inside the task's run() function, after writing to Directus
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({ paths: ['/orders', `/orders/${orderId}`] }),
}
);
The /api/revalidate route handler is in the base stack (see deployment skill). Accept an array of paths to invalidate and call revalidatePath() for each.
Option B: Directus Flow → /api/revalidate
If the task writes to Directus and Directus has a Flow listening for items.update, let the Flow POST to /api/revalidate directly. Simpler but less control over which specific paths to invalidate.
Task Env Vars vs Next.js Env Vars
Tasks run on the Trigger.dev platform, not on your Next.js hosting. They need their own copy of env vars set in the Trigger.dev dashboard. A .env.local variable consumed by Next.js is NOT automatically visible to a task — set it separately in the Trigger dashboard.
Typical task env vars:
NEXT_PUBLIC_DIRECTUS_URL,DIRECTUS_ADMIN_TOKEN— for writes back to DirectusNEXT_PUBLIC_SITE_URL,REVALIDATION_SECRET— for the revalidation callbackOPENAI_API_KEYor whatever third-party key the task uses
Anti-Patterns
| Don't | Do |
|---|---|
await triggerAndPoll in a user-facing Server Action |
Return runId + subscribe with useRealtimeRun |
import { myTask } from Next.js |
import type { myTask } |
onClick={myFormAction} |
onClick={() => myFormAction(arg)} |
Skip force-dynamic on route handlers that call tasks.trigger() |
Always add it |
Pass TRIGGER_SECRET_KEY to client code |
Pass only the scoped publicAccessToken per run |
| Retry failed triggers in a loop inside the route handler | Let Trigger's retry config handle it — keep the route handler thin |
| Forget to set env vars in the Trigger.dev dashboard | Set them per environment (dev/staging/prod) in the Trigger.dev dashboard |