Inngest for Background Jobs in Your Next.js SaaS
A step-by-step Inngest tutorial for Next.js: durable background jobs with retries, steps, and cron scheduling — no queues or worker servers to manage.
· Justin Boggs

Photo by Hyundai Motor Group on Unsplash
Inngest is the fastest way to run durable background jobs in a Next.js SaaS without standing up a queue, a Redis instance, or a separate worker server. You define functions in your normal codebase, send an event when something happens, and Inngest invokes your function over HTTP — handling retries, multi-step checkpointing, and scheduling for you. For a solo founder on Vercel, this solves the single most common backend headache: how to run work that outlives the request without a serverless function timing out mid-task. This tutorial walks through what background jobs are, how Inngest's durable execution model works, and exactly how to wire it into a Next.js app step by step.
TL;DR
- Background jobs run work outside the request/response cycle — sending email, processing uploads, calling slow APIs — so your user is not left waiting and your serverless function does not time out.
- Inngest is a durable execution platform: you send an event, it calls your function over HTTP, retries failed steps automatically, and checkpoints multi-step workflows so a failure halfway through does not restart from zero.
- Setup is three pieces: an Inngest client, a
serveroute at/api/inngest, and one or more functions triggered by events.- The patterns that matter are steps (
step.run), delays (step.sleep), and scheduling (cron triggers) — all in plain TypeScript.- Alternatives exist — Trigger.dev, Vercel Cron, Upstash QStash — and each fits a different shape of problem.
What background jobs are and why a serverless SaaS needs them
A background job is any unit of work your app runs outside the request that triggered it. When a user uploads a file, you want to return "got it" immediately and process the file afterward. When someone signs up, you want to send the welcome email without making them stare at a spinner while your email provider responds. That "afterward" work is a background job.
On a traditional always-on server, this is easy — you just keep working after responding. On a serverless platform like Vercel, it is not, because serverless functions terminate the moment the response is sent, and any async work still running gets killed. That is the core mismatch. The architecture that makes serverless cheap and scalable is the same architecture that has no natural place to put slow work.
The obvious workaround — just make the function run longer — hits a wall too. Vercel functions have a maximum duration. Per Vercel's own guidance, Fluid Compute now lets functions run up to one minute on free plans and up to fourteen minutes on paid plans, with a ceiling around 800 seconds on Pro and Enterprise. That is generous compared to the old ten-second limit, but it is still a ceiling — and a fourteen-minute function that fails at minute thirteen has to start over from the beginning, redoing every side effect along the way.
This is why "one long script that runs for n minutes is often a bad idea anyway," as the Inngest team put it directly. Long-running jobs are prone to failure, and when they fail halfway through you usually lose work or duplicate it. The durable answer is to break the job into individually retryable steps and let something orchestrate them — persisting the output of each step and retrying only the step that failed.
You can reach for Vercel's own primitives here — waitUntil or Next.js's after() keep a function alive briefly after the response is sent — and for genuinely trivial fire-and-forget work, that is enough. But the moment your background work has multiple steps, needs retries, or has to survive a deploy, you want a real orchestrator. That is the gap Inngest fills. If you are still choosing a host, the tradeoffs around function limits are part of the Vercel vs Netlify vs Railway comparison; this post assumes Vercel, which is what the Coding Capybaras stack runs on.
How Inngest works: durable functions over HTTP
Here is the mental model. Inngest does not host your code — your functions keep running on Vercel, in your repo, with your tooling. What Inngest provides is the orchestration layer that decides when to call them and guarantees they finish.
The flow looks like this:
flowchart LR
A[Your app<br/>inngest.send event] --> B[Inngest<br/>durable queue]
B -->|HTTP| C["/api/inngest<br/>on Vercel"]
C --> D[step.run<br/>step 1]
D -->|retry on failure| D
D --> E[step.run<br/>step 2]
E --> F[Done]
Your app sends an event with inngest.send() — for example, app/upload.created. Inngest logs that event and looks up which functions are triggered by it. It then calls your app back over HTTP at a single endpoint, /api/inngest, which is where all your functions are "served." Per the Inngest Next.js quick start, that endpoint is how Inngest discovers your functions and executes them.
The durable part is what happens inside a function. You break the work into steps, and Inngest invokes each step independently over HTTP. Because each step is a separate short request, the sum of all your steps can far exceed the platform's per-request timeout — a workflow that would take 500 seconds as one blocking function runs comfortably as ten steps of 50 seconds each. If a step fails, Inngest retries just that step, and because it checkpoints the output of every completed step, the retry does not redo the work that already succeeded.
That single design decision — checkpoint each step, retry only what failed — is what "durable execution" means, and it is why Inngest works on serverless at all. You get the reliability of a background worker fleet without running one.
There is one operational detail worth knowing up front: because Inngest calls your endpoint over HTTP, the incoming requests are signed. You keep an INNGEST_SIGNING_KEY server-side so your app can verify that a request genuinely came from Inngest and not a random caller. That is the same signature-verification discipline every webhook needs — the same reason you verify Stripe events before trusting them.
Setting up Inngest in a Next.js SaaS, step by step
This walkthrough follows the Coding Capybaras boilerplate's conventions — product code lives under /product, and routes are thin shims generated with pnpm new:route — but the shape is the same in any Next.js App Router project.
1. Get your keys. Create an account at inngest.com, open the dashboard, and under Manage → Keys copy your Event Key and Signing Key. Add them to .env.local:
INNGEST_EVENT_KEY=your_event_key_here
INNGEST_SIGNING_KEY=your_signing_key_here
Keep these in .env.local only — never paste keys into a form or commit them. The Inngest SDK reads both from the environment automatically, so you do not reference them by hand in code. When you deploy, add the same two variables to your Vercel project's Production environment.
2. Install the SDK and create the client + functions. Install inngest, then create product/lib/jobs/inngest.ts. This one file holds your client, your functions, and the serve handler:
// product/lib/jobs/inngest.ts
import { Inngest } from "inngest";
import { serve } from "inngest/next";
export const inngest = new Inngest({ id: "coding-capybaras" });
// An example durable function: process an uploaded file in the background.
export const processUpload = inngest.createFunction(
{ id: "process-upload", triggers: { event: "app/upload.created" } },
async ({ event, step }) => {
const parsed = await step.run("parse-file", async () => {
return await parseUpload(event.data.fileId);
});
await step.run("save-results", async () => {
return await saveResults(event.data.fileId, parsed);
});
return { ok: true, fileId: event.data.fileId };
},
);
export const functions = [processUpload];
// Serve handler — the route shim re-exports these.
export const { GET, POST, PUT } = serve({ client: inngest, functions });
// Trigger helper you call from your app code.
export async function sendUploadCreated(data: {
fileId: string;
userId: string;
}) {
await inngest.send({ name: "app/upload.created", data });
}
3. Wire the API route. Inngest needs an endpoint at /api/inngest that exposes GET, POST, and PUT. Generate the shim with the boilerplate's route command rather than hand-editing /app:
pnpm new:route /app/api/inngest
Then make the generated route a one-line re-export, keeping all real logic in your product lib:
// app/api/inngest/route.ts
export { GET, POST, PUT } from "@/product/lib/jobs/inngest";
4. Run the Dev Server and test it. Inngest ships a local Dev Server that shows every event and run in real time. Start it in a second terminal:
npx inngest-cli@latest dev
Open http://localhost:8288, find your process-upload function, and click Invoke with a payload like { "data": { "fileId": "file_001" } }. You will see the run appear, each step with its own output and timeline. Trigger it from real code by calling sendUploadCreated({ fileId, userId }) wherever the upload actually happens — an API route, a form action, a webhook handler.
5. Deploy. Deploy your Next.js app to Vercel as normal — no special build steps. After deploying, add your two environment variables and sync your app in the Inngest dashboard (the official Vercel integration does this automatically) so Inngest knows where your /api/inngest endpoint lives. On v4, if a workflow's steps together approach the function timeout, set maxDuration on the route and maxRuntime on the client so checkpointing splits them safely.
The patterns that matter: steps, sleep, parallel, and cron
Once the plumbing works, four patterns cover most of what a SaaS actually needs from background jobs.
Steps for reliability. Wrapping work in step.run is not just organization — it is what makes the function durable. Each step is checkpointed and retried independently. A good rule: one side effect per step. Charging a card, sending an email, and writing to the database should be three steps, so a failure in the email send never re-charges the card.
Sleep for delays and drip sequences. step.sleep pauses a function for seconds, hours, or even days, and Inngest resumes it afterward without holding a server open. This makes lifecycle email trivial — a welcome sequence is just steps and sleeps in one readable function:
export const welcomeSequence = inngest.createFunction(
{ id: "welcome-sequence", triggers: { event: "app/account.created" } },
async ({ event, step }) => {
await step.run("send-welcome", () => sendWelcome(event.data.email));
await step.sleep("wait-2-days", "2d");
await step.run("send-tips", () => sendTips(event.data.email));
await step.sleep("wait-3-days", "3d");
await step.run("send-case-study", () => sendCaseStudy(event.data.email));
},
);
That is the entire drip campaign — no cron table, no "has this email been sent yet" flags in your database. If email sequences are your goal specifically, pair this with the design thinking in lifecycle email for indie SaaS and the Resend mechanics in custom email sequences in Resend.
Parallel for throughput. When steps do not depend on each other, kick them off together with Promise.all and Inngest runs them in parallel — importing a batch of records, calling several APIs at once, or processing every image in an upload. This is where background jobs earn their keep for anything that would otherwise block a request while it churns through a list.
Cron for scheduled work. Instead of an event trigger, a function can run on a schedule — a nightly cleanup, a daily digest, a weekly metrics roll-up. You declare the cron expression in the function config and Inngest fires it; the long-running functions writeup shows this alongside the sleep and parallel patterns. Scheduled jobs are also where good observability pays off — when a 3 a.m. job fails, you want Sentry error tracking catching it and PostHog confirming the downstream effect actually happened.
Inngest vs Trigger.dev vs Vercel Cron vs QStash
Inngest is not the only option, and honestly it is overkill for the simplest cases. Here is how the common choices compare for an indie Next.js SaaS:
| | Inngest | Trigger.dev | Vercel Cron | Upstash QStash | | --- | --- | --- | --- | --- | | Model | Event-driven durable functions | Durable background tasks | Scheduled HTTP calls | Message queue + scheduled HTTP | | Runs your code | On your host, via HTTP | Your host or Trigger cloud | Your Vercel functions | Your endpoints, via HTTP | | Automatic retries | Yes, per step | Yes | No — you handle it | Yes, on delivery | | Multi-step checkpointing | Yes | Yes | No | No | | Scheduling / cron | Yes | Yes | Yes — its whole job | Yes | | Free tier | Generous | Yes | Included with Vercel | Generous | | Best for | Event workflows, jobs, and cron in one | Long, heavy, or CPU-bound tasks | Simple recurring jobs | Lightweight queues and delays |
The honest guidance: if all you need is "run this endpoint every night at 2 a.m.," Vercel Cron is built in and you do not need a dependency. If you need a durable queue with delays but not multi-step workflows, Upstash QStash is minimal and cheap. If your jobs are long-running or CPU-heavy — video processing, big data imports — Trigger.dev is designed for that weight. And if you want one tool that handles events, multi-step workflows, delays, and cron with the least infrastructure, Inngest is the one I reach for, because it collapses four separate concerns into plain TypeScript functions.
For most non-technical founders building a standard SaaS, the deciding factor is not raw capability — all four work — it is how much infrastructure you have to think about. Inngest's pitch is that the answer is "almost none," and for a solo founder that is usually the right optimization.
Frequently asked questions
Do I need Redis or a separate server to use Inngest?
No. That is the main reason to use it. Traditional job queues need a Redis instance and a worker process you deploy and monitor. Inngest runs your functions on your existing host over HTTP and manages the queue for you, so there is no extra infrastructure to provision or keep alive.
Will Inngest work on Vercel's free tier?
Yes. Your functions run as normal Vercel functions, and Inngest's own free tier covers a large monthly volume of runs and steps — enough for most early-stage apps. The one thing to watch is per-function duration: keep individual steps under your Vercel plan's timeout, which is exactly what the step model encourages anyway.
How is this different from just using Next.js after() or waitUntil?
after() and waitUntil keep a function alive briefly to finish fire-and-forget work after the response is sent. They are great for a single quick task with no retries. Inngest adds durability: automatic retries, multi-step checkpointing, delays that span days, and scheduling. Use the Vercel primitives for trivial cases and Inngest when the work must reliably finish.
What happens if a background job fails halfway through?
Inngest retries the failed step automatically, and because it checkpoints the output of every step that already succeeded, the retry resumes from the failure point instead of restarting. That is the whole benefit of breaking work into steps — a failure in step three does not re-run steps one and two.
How do I trigger a job from my app code?
Call inngest.send({ name, data }) — or a small wrapper like the sendUploadCreated helper above — from anywhere in your app: an API route, a server action, a form submission, or a webhook handler. Sending the event is all you do; Inngest handles invoking the matching function.
Can I test background jobs locally?
Yes. The Inngest Dev Server (npx inngest-cli@latest dev) gives you a local dashboard at localhost:8288 where you can send events, invoke functions manually, and inspect every step of every run in real time. You develop against it exactly as you would against production, with no keys required locally.
The bottom line
Background jobs are the piece of a SaaS that founders discover late and painfully — usually the first time a Stripe webhook or a slow email send times out in production. Inngest turns that whole category of problem into ordinary TypeScript functions: send an event, break the work into retryable steps, and let the platform guarantee it finishes, on serverless, with no queue to run. For a solo founder, that is the difference between reliable background work and a pager going off at 3 a.m.
If you want the exact setup from this post wired into a working Next.js + Supabase + Stripe app, the Inngest integration guide on Coding Capybaras has the copy-paste prompt — paste it into Claude Code or Cursor and you get the client, the serve route, and your first durable function in one session.