Inngest + Next.js: The Complete Guide (2026)
Source: Dev.to
Serverless functions have a time limit. Vercel gives you 60 seconds on Pro. None of that is enough to send a welcome email sequence, process an uploaded CSV, or run any workflow with multiple external API calls.
Inngest solves this by turning your Next.js API routes into reliable, retryable, observable background jobs — without Redis, without a worker process, without any separate queue infrastructure.
Read the full guide with all code examples at stacknotice.com
How It Works
You define functions that run in response to events. When an event fires, Inngest calls your function via HTTP. If the function fails, Inngest retries it. If the function has multiple steps, Inngest checkpoints each step so a failure halfway through doesn’t restart from scratch.
Setup
npm install inngest
Enter fullscreen mode
Exit fullscreen mode
// lib/inngest/client.ts
import { Inngest } from 'inngest'
export const inngest = new Inngest({ id: 'my-app' })
Enter fullscreen mode
Exit fullscreen mode
// app/api/inngest/route.ts
import { serve } from 'inngest/next'
import { inngest } from '@/lib/inngest/client'
import { welcomeSequence } from '@/lib/inngest/functions/welcome'
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [welcomeSequence],
})
Enter fullscreen mode
Exit fullscreen mode
Welcome Email Sequence
export const welcomeSequence = inngest.createFunction(
{ id: 'welcome-email-sequence', retries: 3 },
{ event: 'user/signed-up' },
async ({ event, step }) => {
const { userId, email, name } = event.data
// Each step is independently retried
await step.run('send-welcome-email', async () => {
await resend.emails.send({
from: 'hello@yourapp.com',
to: email,
subject: `Welcome, ${name}!`,
html: `
Thanks for signing up...
`,
})
})
await step.sleep('wait-2-days', '2 days')
await step.run('send-tips-email', async () => {
await resend.emails.send({
from: 'hello@yourapp.com',
to: email,
subject: 'Top 5 tips to get the most out of the app',
html: `
Here are the tips...
`,
})
})
await step.sleep('wait-5-more-days', '5 days')
const user = await step.run('check-user-plan', async () => {
return db.query.users.findFirst({ where: eq(users.id, userId) })
})
if (user?.plan === 'free') {
await step.run('send-upgrade-email', async () => {
await resend.emails.send({
from: 'hello@yourapp.com',
to: email,
subject: "You've been on the free plan for a week...",
html: `
Here's what Pro unlocks...
`,
})
})
}
}
)
Enter fullscreen mode
Exit fullscreen mode
Trigger from a Route Handler — inngest.send() returns immediately:
await inngest.send({
name: 'user/signed-up',
data: { userId: user.id, email, name },
})
Enter fullscreen mode
Exit fullscreen mode
Steps: The Core Primitive
step.run() is checkpointed — if a later step fails, earlier steps don’t re-run:
export const processUpload = inngest.createFunction(
{ id: 'process-csv-upload', retries: 2 },
{ event: 'file/uploaded' },
async ({ event, step }) => {
const rows = await step.run('parse-csv', async () => {
const response = await fetch(event.data.fileUrl)
return parseCSV(await response.text())
})
const { valid, invalid } = await step.run('validate-rows', async () => {
return validateCSVRows(rows)
})
await step.run('insert-to-database', async () => {
const batches = chunk(valid, 100)
for (const batch of batches) {
await db.insert(contacts).values(batch).onConflictDoNothing()
}
})
await step.run('notify-user', async () => {
await sendNotification(event.data.userId, {
message: `Import complete: ${valid.length} added, ${invalid.length} skipped`,
})
})
}
)
Enter fullscreen mode
Exit fullscreen mode
Fan-Out: Parallel Steps
const [salesReport, usageReport, churnReport] = await Promise.all([
step.run('generate-sales', () => buildSalesReport(month, year)),
step.run('generate-usage', () => buildUsageReport(month, year)),
step.run('generate-churn', () => buildChurnReport(month, year)),
])
// Runs concurrently — waits for all three before continuing
Enter fullscreen mode
Exit fullscreen mode
Cron Jobs
export const weeklyDigest = inngest.createFunction(
{ id: 'weekly-digest', retries: 2 },
{ cron: '0 9 * * MON' }, // Every Monday at 9am UTC
async ({ step }) => {
const users = await step.run('get-users', async () => {
return db.query.users.findMany({ where: eq(users.weeklyDigest, true) })
})
// Fan out to individual per-user events for large lists
await inngest.send(
users.map((user) => ({
name: 'digest/send-to-user',
data: { userId: user.id },
}))
)
return { queued: users.length }
}
)
Enter fullscreen mode
Exit fullscreen mode
waitForEvent: Human-in-the-Loop
Pause a function and wait for an external event to resume it:
export const approvalWorkflow = inngest.createFunction(
{ id: 'content-approval-workflow' },
{ event: 'content/submitted' },
async ({ event, step }) => {
await step.run('notify-reviewer', async () => {
await sendReviewRequest(event.data.reviewerId, event.data.contentId)
})
// Wait up to 48 hours for approval
const approval = await step.waitForEvent('wait-for-approval', {
event: 'content/reviewed',
match: 'data.contentId',
timeout: '48h',
})
if (!approval) {
await step.run('escalate', () => escalateToAdmin(event.data.contentId))
return { status: 'escalated' }
}
if (approval.data.approved) {
await step.run('publish', () => publishContent(event.data.contentId))
return { status: 'published' }
}
}
)
Enter fullscreen mode
Exit fullscreen mode
Type-Safe Events
type Events = {
'user/signed-up': {
data: { userId: string; email: string; name: string; plan: 'free' | 'pro' }
}
'file/uploaded': {
data: { fileUrl: string; userId: string; fileName: string }
}
}
export const inngest = new Inngest({
id: 'my-app',
schemas: new EventSchemas().fromRecord(),
})
// inngest.send() now type-checks event names and data shapes
Enter fullscreen mode
Exit fullscreen mode
Error Handling
import { NonRetriableError } from 'inngest'
// Retryable error — just throw
throw new Error('External API failed')
// Non-retryable — use NonRetriableError
throw new NonRetriableError('Invalid email — will not retry')
Enter fullscreen mode
Exit fullscreen mode
Inngest vs Trigger.dev
Feature Inngest Trigger.dev
Step functions
step.run()
task()
waitForEvent ✅ Built-in Manual polling
Self-host ❌ ✅ Open-source
Free tier 50k runs/month 50k runs/month
Choose Inngest for human-in-the-loop workflows. Choose Trigger.dev for self-hosting.
Local Dev
npx inngest-cli@latest dev
Enter fullscreen mode
Exit fullscreen mode
Visual dashboard at http://localhost:8288 — trigger test events, inspect step-by-step execution. No cloud account needed for local dev.
Full guide with deployment config, advanced retries, and fan-out patterns: stacknotice.com/blog/inngest-nextjs-complete-guide-2026
