DEV Community

nareshipme
nareshipme

Posted on

Background Jobs in Next.js 15 with Inngest: Step Functions, Type-Safe Events, and TDD

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
Enter fullscreen mode Exit fullscreen mode

Create src/lib/inngest.ts:

import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "my-app" });
Enter fullscreen mode Exit fullscreen mode

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" };
  }
);
Enter fullscreen mode Exit fullscreen mode

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],
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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" }),
  });
});
Enter fullscreen mode Exit fullscreen mode

6. Local Dev

Run the Inngest dev server alongside Next.js:

npx inngest-cli@latest dev
Enter fullscreen mode Exit fullscreen mode

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)