Inngest Guide: Event-Driven Functions and Workflows in Next.js
Inngest turns Next.js API routes into durable, event-driven background functions — no queue config, no Redis, no ops overhead. Here's how it actually works.
What Inngest Actually Is (And Why You'd Care)
Inngest is a background job and workflow runtime that hooks into your existing HTTP server — Next.js, Express, Fastify, whatever. You write plain TypeScript functions, register them with Inngest, and they become durable, retriable, event-driven workers. No separate queue process. No Redis or SQS to provision. No Celery config to maintain. It just works inside your existing deployment.
The mental model is straightforward: your app fires *events* (JSON objects with a name and data payload), and Inngest routes those events to the functions that care about them. Functions can sleep for minutes or days, fan out into parallel steps, wait for a follow-up event before continuing, and automatically retry on failure — all without you managing any of that state. Inngest keeps the execution log and state machine on their side.
This is genuinely different from a simple cron job or a fire-and-forget fetch. In practice, Inngest functions behave more like serverless orchestration primitives than simple workers. You get step-level retries, durable sleep, event coordination, and a visual execution trace in the Inngest dashboard — all in a package that integrates with Next.js in about 15 minutes.
Worth noting: Inngest v3 (released in late 2024) changed the SDK's step API significantly. The examples in this article use the v3 @inngest/core API. If you're on an older project, check their migration docs before copy-pasting.
Setting Up Inngest in a Next.js App Router Project
Install the package first:
npm install inngestThen create the Inngest client. One file, one export — you'll import this everywhere.
// lib/inngest.ts
import { Inngest } from 'inngest';
export const inngest = new Inngest({ id: 'my-app' });Next you need a route handler. Inngest talks to your app over HTTP, so you expose a single endpoint — typically /api/inngest — that the Inngest cloud (or local dev server) pings to execute your functions.
// app/api/inngest/route.ts
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { sendWelcomeEmail } from '@/inngest/functions';
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmail],
});That serve() call registers your functions and wires up the three HTTP methods Inngest uses to handshake with your app. GET is for the SDK's introspection endpoint, POST is where jobs get dispatched, PUT syncs your function definitions. One route, three verbs, done.
Writing Your First Background Function
Here's a real-world example: send a welcome email after user signup, then 48 hours later send an onboarding nudge if they haven't completed setup. This is the kind of workflow that normally requires a cron job, a flag in your database, and a separate worker process. With Inngest it's one function.
// inngest/functions.ts
import { inngest } from '@/lib/inngest';
import { sendEmail } from '@/lib/email';
import { db } from '@/lib/db';
export const sendWelcomeEmail = inngest.createFunction(
{ id: 'send-welcome-email' },
{ event: 'user/signed-up' },
async ({ event, step }) => {
// Step 1 — send welcome email immediately
await step.run('send-welcome', async () => {
await sendEmail({
to: event.data.email,
subject: 'Welcome aboard',
template: 'welcome',
});
});
// Step 2 — wait 48 hours (durable sleep, no cron needed)
await step.sleep('wait-48h', '48 hours');
// Step 3 — check if they've finished onboarding
const user = await step.run('check-onboarding', async () => {
return db.user.findUnique({ where: { email: event.data.email } });
});
if (!user?.onboardingComplete) {
await step.run('send-nudge', async () => {
await sendEmail({
to: event.data.email,
subject: 'One quick thing to finish',
template: 'onboarding-nudge',
});
});
}
}
);Each step.run() call is independently retriable. If the nudge email fails at step 4, Inngest retries from step 4 — not from the beginning. The 48-hour sleep is fully durable: your serverless function exits after the sleep call, Inngest resumes it later. No long-polling, no held connections.
Honestly, this is where Inngest earns its keep. That whole workflow above — with proper retry logic, durable sleep, and conditional branching — would have taken you a day to build with raw queues. It's 40 lines here.
To fire the event from your app, call inngest.send() anywhere you'd normally complete a signup:
// app/api/auth/signup/route.ts
import { inngest } from '@/lib/inngest';
export async function POST(req: Request) {
const { email } = await req.json();
// ... create user in DB ...
await inngest.send({
name: 'user/signed-up',
data: { email, userId: newUser.id },
});
return Response.json({ ok: true });
}Step Primitives You'll Use Every Day
Inngest's step object is the API surface you'll live in. It's small but covers most orchestration patterns you'll ever need.
step.run(id, fn) — runs a function as a distinct, retriable step. Every step.run is memoized: if the overall function retries, completed steps are replayed from cache and only the failed step re-executes. This is the most important property to internalize. It means you never double-send an email or double-charge a user just because a downstream step crashed.
step.sleep(id, duration) — durably pauses execution. You pass a string like '5 minutes', '3 days', or '1 week'. Your function just awaits it. The serverless function actually exits and Inngest re-invokes it at the right time.
step.waitForEvent(id, options) — suspends until a specific event arrives. This is powerful for human-in-the-loop workflows: pause an approval workflow until a 'review/approved' event fires, with an optional timeout if nobody approves within X hours.
const approval = await step.waitForEvent('wait-for-approval', {
event: 'review/approved',
timeout: '72 hours',
// only match events for this specific document
match: 'data.documentId',
});
if (!approval) {
// timeout hit — escalate or abort
}step.sendEvent(id, events) — fires one or more events from within a function, enabling fan-out patterns. Trigger 50 per-user processing jobs from a single batch event in one line.
Quick aside: step.invoke() lets one Inngest function call another and await its return value — this is how you compose large workflows from small, reusable units without coupling them to a direct function call.
Local Development with the Inngest Dev Server
The Inngest dev server is a local dashboard that mimics the cloud environment. Run it alongside your Next.js dev server and you get a full execution trace, event browser, and replay UI — no internet required.
# Terminal 1 — Next.js
npm run dev
# Terminal 2 — Inngest dev server
npx inngest-cli@latest devOpen http://localhost:8288 and you'll see the Inngest dashboard. Your registered functions show up automatically once the dev server discovers your /api/inngest endpoint. Send a test event from the UI, watch the function execute step-by-step, inspect inputs and outputs at each step. When a step fails you can fix the code and replay just that event without re-triggering the original user action.
In practice this cuts debugging time dramatically. You're not manually recreating edge cases or tailing logs across multiple services — you click *Replay*, tweak your code, and see the corrected execution in 3 seconds. That alone is worth adopting Inngest for.
One more thing — the dev server syncs your function definitions on every save (with Next.js fast refresh). So you don't have to restart anything when you add a new function or change a step. The developer loop feels noticeably tighter than traditional queue-based setups.
Fan-Out, Concurrency, and Rate Limiting
Single-event workflows are simple. The real power shows up when you need to process thousands of items in parallel, enforce rate limits against a third-party API, or prevent concurrent runs from tripping over each other.
Fan-out is trivial with step.sendEvent. Imagine processing a CSV of 1,000 users — you fire one batch/upload event, your function reads the rows and sends 1,000 individual user/process events, each of which triggers its own function run in parallel. No for-loop timeout issues, no memory blowout.
export const processBatch = inngest.createFunction(
{ id: 'process-batch' },
{ event: 'batch/upload' },
async ({ event, step }) => {
const rows = event.data.rows; // array of 1000 users
await step.sendEvent(
'fan-out-users',
rows.map((row) => ({
name: 'user/process',
data: row,
}))
);
}
);For rate limiting — say you're calling an API that allows 10 requests per second — set rateLimit in your function config:
export const syncToHubspot = inngest.createFunction(
{
id: 'sync-to-hubspot',
rateLimit: {
limit: 10,
period: '1s',
key: 'event.data.accountId', // per-account rate limit
},
},
{ event: 'crm/sync-contact' },
async ({ event, step }) => {
// Inngest queues excess events, auto-releases within the rate window
await step.run('sync', () => hubspot.createContact(event.data));
}
);Concurrency is controlled the same way — add a concurrency key to cap how many runs of a function execute simultaneously. Set key to an expression like 'event.data.userId' and you get *per-user* concurrency limits without any mutex logic in your code. What would've required Redis and a distributed lock is now a 3-line config object.
If you're building an app with complex UI that needs to reflect workflow state, pair this with a well-structured frontend. Empire UI's component library ships cards, progress indicators, and status badges that map cleanly to Inngest's execution states — running, completed, failed, sleeping.
Deploying to Production and What to Watch Out For
In production, Inngest's cloud communicates with your deployed app the same way the dev server does — over HTTP. You give Inngest your deployed URL and a signing key to verify requests, and you're done. No port forwarding, no VPNs, no sidecar containers.
Set the signing key as an environment variable and Inngest validates every incoming request automatically:
# .env.production
INNGEST_SIGNING_KEY=signkey-prod-xxxxxxxxxxxx
INNGEST_EVENT_KEY=xxxxxxxxxxxxxxxxThe INNGEST_EVENT_KEY is what you use to send events server-side. It's separate from the signing key intentionally — one for inbound auth, one for outbound. Keep both out of your client bundle.
A few production gotchas worth knowing. First: Vercel's default function timeout is 10 seconds on Hobby, 60 seconds on Pro. Since Inngest functions make HTTP callbacks to your deployed app for each step, individual step execution has to complete within your timeout. Long-running computation inside a single step.run call needs to finish within that window. Break big work into smaller steps. Second: on serverless platforms, your function handler *must* return a response quickly — Inngest does the orchestration, so your API route doesn't hold an open connection for the full workflow duration. This is actually a feature, not a limitation.
Look, Inngest isn't free at scale. The free tier covers 50,000 function runs per month as of 2026, which is generous for side projects but will require a paid plan once you're processing real volume. Check their pricing before assuming it's free forever — the architecture decision should factor in the cost at your expected run count.
For the UI layer of your app, the design system you pair with Inngest matters. Building a dashboard to surface workflow status, job history, or async operation results? Browse the Empire UI component library — the data table, status badge, and timeline components are particularly useful for async workflow UIs. You can also check the templates section for full dashboard starters you can drop Inngest status data into directly.
FAQ
Yes, Inngest works with the App Router using the inngest/next adapter's serve() function. Edge Runtime support exists but has limitations — step.sleep and step.waitForEvent require a Node.js runtime because they rely on longer execution contexts. Stick to Node.js runtime for functions that use those primitives.
A regular await someFunction() inside an Inngest function has no retry or memoization guarantees. step.run() wraps the call in a discrete, independently retriable unit — if the overall function fails and retries, completed steps are replayed from cache, not re-executed. Always use step.run for any side-effecting work like database writes or external API calls.
Yes — Inngest has an open-source self-hosted option. You run the Inngest server yourself and point your app's signing key at your own instance. It's more ops work but gives you full control over data residency. The local dev server (inngest-cli dev) is also fully self-contained and requires no internet connection.
The Inngest cloud dashboard shows a full execution trace for every function run, including which step failed, the error message, input/output at each step, and retry history. You can replay any failed run directly from the dashboard after deploying a fix. No log diving or manual event reconstruction needed.