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

작성자

카테고리:

← 피드로
DEV Community · Carlos Oliva Pascual · 2026-06-11 개발(SW)

Carlos Oliva Pascual

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: '[email protected]',
        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: '[email protected]',
        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: '[email protected]',
          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

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다