DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Inngest + Next.js: The Complete Guide (2026)

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: `<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>`,
        })
      })
    }
  }
)
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<Events>(),
})
// 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

Top comments (0)