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
// lib/inngest/client.ts
import { Inngest } from 'inngest'
export const inngest = new Inngest({ id: 'my-app' })
// 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],
})
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: `<p>Thanks for signing up...</p>`,
})
})
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: `<p>Here are the tips...</p>`,
})
})
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: `<p>Here's what Pro unlocks...</p>`,
})
})
}
}
)
Trigger from a Route Handler — inngest.send() returns immediately:
await inngest.send({
name: 'user/signed-up',
data: { userId: user.id, email, name },
})
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`,
})
})
}
)
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
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 }
}
)
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' }
}
}
)
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<Events>(),
})
// inngest.send() now type-checks event names and data shapes
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')
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
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
Top comments (0)