For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Inngest
Add Inngest to TurboStarter for event-driven background jobs, cron schedules, and durable multi-step workflows with automatic retries.
Inngest is an event-driven background jobs platform. You define durable functions in TypeScript, publish events from your app, and Inngest runs each step with automatic retries, observability, and scheduling, without operating your own queue workers.
What Inngest is good for
Use Inngest when work should start from events (user signed up, invoice paid, export requested) or cron, and each job may need multiple durable steps, sleeps, or fan-out. Functions live in your repo; Inngest handles delivery, retries, and the execution dashboard.
When to choose Inngest
| Need | Prefer |
|---|---|
| Event-driven workflows, multi-step fan-out, long sleeps between steps | Inngest |
| Dedicated workers, long CPU-heavy jobs, first-class DX in the monorepo | Trigger.dev |
| Simple HTTP callbacks, delay/cron without a second control plane | Upstash QStash |
| Durable orchestration that stays on Vercel’s runtime | Vercel Workflows |
Pick Inngest if you think in events (“when X happens, run Y”), want typed event contracts, and need step-level retries without managing Redis or a queue. Skip it if you only need a one-shot HTTP callback (QStash is lighter), or you already standardized on Trigger.dev and do not want a second platform.
Create an Inngest app and keys
- Sign up at Inngest and create an app.
- Copy the Event key (publish events) and Signing key (authenticate the serve endpoint).
Add them to the root env file (never commit real values):
INNGEST_EVENT_KEY=your_event_key_here
INNGEST_SIGNING_KEY=your_signing_key_hereRegister both as server-only variables when you enable the recipe. Use .optional() until you rely on Inngest in every environment, then require them in production validation.
server: {
// Existing variables...
INNGEST_EVENT_KEY: z.string().min(1).optional(),
INNGEST_SIGNING_KEY: z.string().min(1).optional(),
},server: {
// Existing variables...
INNGEST_EVENT_KEY: z.string().min(1).optional(),
INNGEST_SIGNING_KEY: z.string().min(1).optional(),
},The serve route runs in the web app; event sends usually run from the API package. Validate the keys in both places you read them. Use separate Inngest environments and keys for local, preview, and production. See Secrets & environment.
Install the SDK
This guide targets the current Inngest TypeScript SDK v4 API (triggers, eventType). Install the latest major and pin it in the lockfile so upgrades stay intentional:
pnpm add --filter @workspace/api inngest
pnpm add --filter web inngestThe web app needs the package for inngest/next serve. The API package needs it to create functions and call inngest.send().
Expose the Inngest modules from the API package so the web app can import them:
{
"exports": {
".": "./src/index.ts",
"./schema/*": "./src/schema/*.ts",
"./constants/*": "./src/constants/*.ts",
"./inngest": "./src/lib/inngest/index.ts",
"./inngest/*": "./src/lib/inngest/*.ts"
}
}Create the Inngest client and event types
Prefer decentralized eventType() definitions (v4) over a single global schema map. That keeps send sites, triggers, and event.data on one contract with optional Zod runtime validation.
import { eventType, Inngest } from "inngest";
import * as z from "zod";
export const inngest = new Inngest({
id: "turbostarter",
});
export const userDataProcess = eventType("user/data.process", {
schema: z.object({
userId: z.string(),
operation: z.enum(["export", "analyze", "cleanup"]),
}),
});The SDK reads INNGEST_EVENT_KEY from the environment when sending. The Next.js serve handler uses INNGEST_SIGNING_KEY to verify Inngest requests.
Define durable functions
Put functions next to the client so the API and the serve route share one module graph.
import { inngest, userDataProcess } from "../client";
export const processUserData = inngest.createFunction(
{
id: "process-user-data",
triggers: [userDataProcess],
},
async ({ event, step }) => {
const { userId, operation } = event.data;
return await step.run("process-data", async () => {
switch (operation) {
case "export":
// Load the user from the DB using userId, then export.
await new Promise((resolve) => setTimeout(resolve, 2000));
return { success: true, result: "Data exported to CSV" };
case "analyze":
await new Promise((resolve) => setTimeout(resolve, 5000));
return {
success: true,
result: { totalActions: 156, avgSessionTime: "4m 32s" },
};
case "cleanup":
await new Promise((resolve) => setTimeout(resolve, 3000));
return { success: true, result: "Removed 23 obsolete records" };
default: {
const _exhaustive: never = operation;
throw new Error(`Unknown operation: ${_exhaustive}`);
}
}
});
},
);import { cron } from "inngest";
import { inngest } from "../client";
export const dailyCleanup = inngest.createFunction(
{
id: "daily-cleanup",
triggers: [cron("0 2 * * *")], // Daily at 02:00 UTC
},
async ({ step }) => {
await step.run("cleanup-logs", async () => {
// Delete or archive old log rows
return { logsCleaned: true };
});
await step.run("cleanup-temp-files", async () => {
// Remove expired uploads from storage
return { tempFilesCleaned: true };
});
await step.run("generate-reports", async () => {
// Build daily ops report
return { reportsGenerated: true };
});
},
);Export a barrel for the serve route and web imports:
export { inngest, userDataProcess } from "./client";
export { dailyCleanup } from "./functions/daily-cleanup";
export { processUserData } from "./functions/process-user-data";Each step.run() is retried independently. Prefer small steps over one long handler so a failed email send does not redo an expensive export.
Serve functions from Next.js
Inngest invokes your app over HTTP. Add an App Router handler at /api/inngest:
import { serve } from "inngest/next";
import { dailyCleanup, inngest, processUserData } from "@workspace/api/inngest";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [processUserData, dailyCleanup],
});Keep this route unauthenticated by your session middleware. Inngest authenticates with the signing key instead.
Develop locally with the Dev Server
Run the web app, then point the Inngest Dev Server at your serve URL:
pnpm --filter web dev
npx inngest-cli@latest dev -u http://localhost:3000/api/inngestThe Dev Server syncs function definitions, lets you send test events, and shows step timelines without deploying. Use it before you wire production keys.
Trigger work from TurboStarter (tRPC)
Never accept a userId from the browser as authorization. Take the authenticated session from protectedProcedure, then publish only the identifiers the worker needs.
import * as z from "zod";
import { inngest, userDataProcess } from "../../lib/inngest/client";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const tasksRouter = createTRPCRouter({
processUserData: protectedProcedure
.input(
z.object({
operation: z.enum(["export", "analyze", "cleanup"]),
}),
)
.mutation(async ({ ctx, input }) => {
await inngest.send(
userDataProcess.create({
userId: ctx.session.user.id,
operation: input.operation,
}),
);
return {
success: true,
message: "Background task queued",
};
}),
});Register the router:
import { tasksRouter } from "./modules/tasks/tasks.router";
export const appRouter = createTRPCRouter({
// ...existing routers
tasks: tasksRouter,
});From a client component
"use client";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "~/trpc/react";
export function ProcessDataButton() {
const trpc = useTRPC();
const { mutate, isPending } = useMutation(
trpc.tasks.processUserData.mutationOptions({
onSuccess: () => {
console.log("Task queued");
},
}),
);
return (
<button
type="button"
disabled={isPending}
onClick={() => mutate({ operation: "analyze" })}
>
{isPending ? "Queueing..." : "Analyze my data"}
</button>
);
}From a server action
"use server";
import { api } from "~/trpc/server";
export async function processUserData(
operation: "export" | "analyze" | "cleanup",
) {
return api.tasks.processUserData({ operation });
}For organization-scoped jobs, verify membership (or a stored job row the user owns) before inngest.send(), and pass a job ID (not a full customer record) into the event payload.
Security checklist
- Derive
userId/organizationIdfrom the session or a DB row you already authorized, never from an untrusted client field alone. - Keep
INNGEST_EVENT_KEYandINNGEST_SIGNING_KEYserver-only (noNEXT_PUBLIC_prefix). - Make steps idempotent: retries are expected. Claim work with a status column (
pending→processing→done) or an idempotency key. - Log stable IDs (user, job, run), not secrets or full PII payloads.
- Re-check permissions inside delayed steps if access can change after the event was sent.
Monitoring and debugging
Use the Inngest Dashboard to:
- Inspect function runs, step timelines, and failure reasons
- Compare success vs failure rates and duration
- Replay failed runs after you ship a fix
- Configure alerts on failure spikes
Locally, prefer the Dev Server over guessing from application logs alone.
Best practices
await step.run("fetch-source", async () => fetchSource(jobId));
await step.run("transform", async () => transform(jobId));
await step.run("notify", async () => notify(jobId));A failed notify step should not re-download the source.
// Good
{
id: "user-data-export-csv";
}
// Hard to operate
{
id: "task1";
}await step.run("charge", async () => {
try {
return await chargeCustomer(invoiceId);
} catch (error) {
console.error("Charge failed", { invoiceId, error });
throw error; // Re-throw for retry
}
});Use non-retryable errors only when the input is permanently invalid.
Send IDs and enums. Load large blobs inside the step from your database or storage. Smaller events are safer to log and easier to version.
FAQ
Use Trigger.dev for dedicated background workers and long-running jobs that feel like “tasks” in a tasks package. Use Inngest when the product model is “react to events” with multi-step, sleep, and fan-out. Many teams pick one platform and stick to it to reduce ops overhead.
No. Inngest hosts the queue and orchestration. Your Next.js app exposes /api/inngest and runs function steps when Inngest calls it.
Each step should finish within your platform’s function limit. Long workflows are fine if you split them into steps (and use step.sleep for delays). Avoid one giant step that runs for minutes on a short-timeout plan.
Pass cron("0 2 * * *") (or an array of triggers) in the function options. Cron runs are visible in the same dashboard as event-driven runs.
Next steps
With Inngest wired into TurboStarter you can:
- Queue reliable background jobs from authenticated tRPC mutations
- Schedule maintenance with cron triggers
- Compose multi-step workflows with independent retries
- Observe and replay failures from the Inngest dashboard
Compare alternatives in the background tasks overview, or go deeper in the official docs.
How is this guide?
Last updated on