For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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

NeedPrefer
Event-driven workflows, multi-step fan-out, long sleeps between stepsInngest
Dedicated workers, long CPU-heavy jobs, first-class DX in the monorepoTrigger.dev
Simple HTTP callbacks, delay/cron without a second control planeUpstash QStash
Durable orchestration that stays on Vercel’s runtimeVercel 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

  1. Sign up at Inngest and create an app.
  2. Copy the Event key (publish events) and Signing key (authenticate the serve endpoint).

Add them to the root env file (never commit real values):

.env.local
INNGEST_EVENT_KEY=your_event_key_here
INNGEST_SIGNING_KEY=your_signing_key_here

Register 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.

apps/web/env.config.ts
server: {
  // Existing variables...
  INNGEST_EVENT_KEY: z.string().min(1).optional(),
  INNGEST_SIGNING_KEY: z.string().min(1).optional(),
},
packages/api/src/env/index.ts
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 inngest

The 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:

packages/api/package.json
{
  "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.

packages/api/src/lib/inngest/client.ts
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.

packages/api/src/lib/inngest/functions/process-user-data.ts
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}`);
        }
      }
    });
  },
);

Export a barrel for the serve route and web imports:

packages/api/src/lib/inngest/index.ts
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:

apps/web/src/app/api/inngest/route.ts
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/inngest

The 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.

packages/api/src/modules/tasks/tasks.router.ts
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:

packages/api/src/router.ts
import { tasksRouter } from "./modules/tasks/tasks.router";

export const appRouter = createTRPCRouter({
  // ...existing routers
  tasks: tasksRouter,
});

From a client component

apps/web/src/modules/tasks/process-data-button.tsx
"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

apps/web/src/app/actions/user-actions.ts
"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 / organizationId from the session or a DB row you already authorized, never from an untrusted client field alone.
  • Keep INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY server-only (no NEXT_PUBLIC_ prefix).
  • Make steps idempotent: retries are expected. Claim work with a status column (pendingprocessingdone) 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

FAQ

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

On this page

Ship your startup everywhere. In minutes.Try TurboStarter