Background jobs are one of those things that seem simple until they're not. You need retries, observability, step-level restarts, and a way to test without spinning up a real queue. Inngest solves all of this with a developer-friendly API that integrates cleanly with Next.js 15 App Router.
Here's a practical guide to wiring it up — including the parts the docs gloss over.
The Stack
- Next.js 15 (App Router)
- TypeScript
- Inngest (background jobs + queues)
- Vitest (testing)
1. Install and Create the Client
npm install inngest
Create src/lib/inngest.ts:
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "my-app" });
That's your singleton client. You'll import it everywhere else.
2. Create a Function
Inngest functions are typed, step-based handlers. Each step.run() call is independently retried if it fails.
// src/inngest/functions/process-video.ts
import { inngest } from "@/lib/inngest";
export const processVideo = inngest.createFunction(
{ id: "process-video" },
{ event: "video/process" },
async ({ event, step }) => {
const { projectId, r2Key, userId } = event.data as {
projectId: string;
r2Key: string;
userId: string;
};
// Each step.run() block is atomic + retried independently
await step.run("update-status-processing", async () => {
// e.g. update DB, call external API, etc.
});
await step.run("transcribe-audio", async () => {
// heavy AI work here — only retried if THIS step fails
});
return { projectId, status: "completed" };
}
);
Why steps matter: Without steps, if your function fails halfway through, it retries from the beginning — re-running expensive or side-effectful operations. Steps give you checkpointing for free.
3. Serve the Functions via an API Route
Inngest talks to your app via HTTP. Create src/app/api/inngest/route.ts:
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest";
import { processVideo } from "@/inngest/functions/process-video";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [processVideo],
});
This exposes GET /api/inngest for the Inngest dev server to discover your functions, and POST /api/inngest to receive event-triggered invocations.
4. Trigger a Job from an API Route
From any API handler, trigger your background function by sending an event:
// src/app/api/projects/[id]/process/route.ts
import { inngest } from "@/lib/inngest";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Update status to processing synchronously
await db.projects.update({ status: "processing" }).where({ id });
// Trigger the background job
await inngest.send({
name: "video/process",
data: { projectId: id, r2Key: "...", userId: "..." },
});
return Response.json({ status: "processing" }, { status: 200 });
}
The API returns immediately. Inngest handles the rest asynchronously.
5. Test It Without a Real Queue
This is the part most guides skip. Here's how to test the serve route with Vitest:
// src/app/api/inngest/__tests__/route.test.ts
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/inngest", () => ({
inngest: { id: "test-app" },
}));
vi.mock("@/inngest/functions/process-video", () => ({
processVideo: { id: "process-video" },
}));
vi.mock("inngest/next", () => ({
serve: vi.fn(() => ({
GET: vi.fn(() => new Response("ok", { status: 200 })),
POST: vi.fn(() => new Response("ok", { status: 200 })),
PUT: vi.fn(() => new Response("ok", { status: 200 })),
})),
}));
describe("GET /api/inngest", () => {
it("responds with 200", async () => {
const { GET } = await import("../route");
const res = await GET(new Request("http://localhost/api/inngest"));
expect(res.status).toBe(200);
});
});
And for the trigger route, mock inngest.send and assert it's called with the right payload:
const mockSend = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/inngest", () => ({
inngest: { send: mockSend },
}));
it("sends the inngest event", async () => {
const res = await POST(req, { params: Promise.resolve({ id: "proj-1" }) });
expect(res.status).toBe(200);
expect(mockSend).toHaveBeenCalledWith({
name: "video/process",
data: expect.objectContaining({ projectId: "proj-1" }),
});
});
6. Local Dev
Run the Inngest dev server alongside Next.js:
npx inngest-cli@latest dev
It auto-discovers your serve route at http://localhost:3000/api/inngest, lets you inspect events, trigger functions manually, replay failures, and view step-level logs — all locally.
Gotchas
params is async in Next.js 15. Dynamic route params are now Promise<{ id: string }>. Always await params before destructuring.
Don't call step.run() inside conditionals if you can avoid it. Inngest replays your function from the top to resume after a step — conditional steps can behave unexpectedly.
Keep steps coarse-grained. A step has overhead. Don't wrap every line — group related operations into a single step.
Type your events. Inngest supports typed events via generics. Use them — it prevents silent mismatches between trigger and handler.
Summary
| Thing | How Inngest Handles It |
|---|---|
| Retries | Per step, automatic |
| Observability | Built-in dev UI + cloud dashboard |
| Local dev | inngest-cli dev |
| Testing | Mock client + send, assert payload |
| Scheduling | inngest.createScheduledFunction |
Inngest is one of those libraries that actually improves your architecture rather than just handling plumbing. If you're building anything async in Next.js — video processing, email pipelines, AI inference — it's worth the 20 minutes to wire up.
What are you using for background jobs in Next.js? Drop it in the comments.
Top comments (0)