Build Durable Functions and Event-Driven Workflows with Inngest and Next.js

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Modern applications are built on events — a user signs up, a payment succeeds, a file is uploaded, an AI model finishes processing. Each event triggers work that must be reliable: send a confirmation email, update billing records, resize images, or kick off a multi-step pipeline. But wiring this up yourself with queues, workers, and retry logic is painful.

Inngest takes a different approach. Instead of managing infrastructure, you write durable functions — TypeScript functions that automatically survive failures, retries, and serverless timeouts. Each function is triggered by events and composed of steps that run exactly once, even if the function is interrupted and re-executed. There are no queues to manage, no workers to deploy, and no Redis instances to babysit.

In this tutorial, you will build a SaaS onboarding and billing pipeline — when a user signs up, Inngest orchestrates a multi-step workflow that provisions their account, sends a welcome email, starts a trial timer, and handles the downstream billing events. You will learn durable steps, fan-out patterns, event coordination, scheduled functions, and throttling.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • Basic knowledge of React and TypeScript
  • Familiarity with Next.js App Router (API routes, Server Actions)
  • A code editor (VS Code recommended)

No Inngest account is required for local development — the Inngest Dev Server runs entirely on your machine.

What You Will Build

A SaaS onboarding and billing pipeline with:

  • Account provisioning function — creates database records and sets up defaults when a user signs up
  • Welcome email function — sends a formatted onboarding email with retry logic
  • Trial management workflow — starts a 14-day trial and sends reminders at day 7 and day 13
  • Fan-out pattern — triggers multiple independent functions from a single event
  • Event coordination — waits for a billing/subscription.created event before continuing
  • Scheduled function — daily churn-risk report for the team
  • Throttling and concurrency — prevents overwhelming external APIs
  • Full local observability — trace every step in the Inngest Dev Server dashboard

Step 1: Create the Next.js Project

Scaffold a fresh Next.js 15 project:

npx create-next-app@latest inngest-saas --typescript --tailwind --eslint --app --src-dir --use-npm
cd inngest-saas

Install Inngest:

npm install inngest

That is the only dependency you need. Inngest has zero peer dependencies and works with any Node.js runtime.

Step 2: Initialize the Inngest Client

Create the shared Inngest client that all your functions will use.

// src/inngest/client.ts
import { Inngest } from "inngest";
 
export const inngest = new Inngest({
  id: "inngest-saas",
});

The id identifies your application. In production, all functions registered under this ID appear together in the Inngest dashboard.

You can also define your event types for full type safety:

// src/inngest/client.ts
import { Inngest } from "inngest";
 
type Events = {
  "user/signup.completed": {
    data: {
      userId: string;
      email: string;
      name: string;
      plan: "free" | "pro" | "enterprise";
    };
  };
  "billing/subscription.created": {
    data: {
      userId: string;
      subscriptionId: string;
      plan: "pro" | "enterprise";
    };
  };
  "user/trial.started": {
    data: {
      userId: string;
      trialEndDate: string;
    };
  };
};
 
export const inngest = new Inngest({
  id: "inngest-saas",
  schemas: new EventSchemas().fromRecord<Events>(),
});

With this setup, every inngest.send() call and every function trigger is fully typed — you get autocomplete on event names and compile-time checks on payloads.

Step 3: Create the API Route

Inngest works by registering a single HTTP endpoint in your application. The Inngest Dev Server (and the cloud service in production) calls this endpoint to invoke your functions.

// src/app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { provisionAccount } from "@/inngest/functions/provision-account";
import { sendWelcomeEmail } from "@/inngest/functions/send-welcome-email";
import { manageTrialWorkflow } from "@/inngest/functions/manage-trial";
import { dailyChurnReport } from "@/inngest/functions/daily-churn-report";
 
export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [
    provisionAccount,
    sendWelcomeEmail,
    manageTrialWorkflow,
    dailyChurnReport,
  ],
});

Every function you write gets imported here and passed to serve(). Inngest handles routing, invocation, and retries through this single endpoint.

Step 4: Build the Account Provisioning Function

Your first durable function. When a user signs up, this function creates database records and sets up their workspace.

// src/inngest/functions/provision-account.ts
import { inngest } from "@/inngest/client";
 
export const provisionAccount = inngest.createFunction(
  {
    id: "provision-account",
    retries: 3,
  },
  { event: "user/signup.completed" },
  async ({ event, step }) => {
    // Step 1: Create user record in database
    const user = await step.run("create-user-record", async () => {
      // In production, this would be a database call
      console.log(`Creating user record for ${event.data.email}`);
      return {
        id: event.data.userId,
        email: event.data.email,
        name: event.data.name,
        plan: event.data.plan,
        createdAt: new Date().toISOString(),
      };
    });
 
    // Step 2: Create default workspace
    const workspace = await step.run("create-workspace", async () => {
      console.log(`Creating workspace for user ${user.id}`);
      return {
        workspaceId: `ws_${user.id}`,
        name: `${user.name}'s Workspace`,
      };
    });
 
    // Step 3: Seed default settings
    await step.run("seed-defaults", async () => {
      console.log(`Seeding defaults for workspace ${workspace.workspaceId}`);
      // Create default project, notification settings, etc.
      return { seeded: true };
    });
 
    // Step 4: Send the trial-started event
    await step.sendEvent("start-trial", {
      name: "user/trial.started",
      data: {
        userId: user.id,
        trialEndDate: new Date(
          Date.now() + 14 * 24 * 60 * 60 * 1000
        ).toISOString(),
      },
    });
 
    return { user, workspace, status: "provisioned" };
  }
);

Why Steps Matter

Each step.run() call is a checkpoint. If the function crashes after Step 2, Inngest will re-invoke it, but Steps 1 and 2 will not re-execute — their results are memoized. This is what makes the function durable. You get exactly-once semantics for each step without any extra infrastructure.

Key properties of steps:

  • Memoized — once a step completes, its return value is cached and reused on retry
  • Individually retriable — if Step 3 fails, only Step 3 is retried
  • Serializable — step results must be JSON-serializable (no class instances or functions)

Step 5: Build the Welcome Email Function

This function also triggers on user/signup.completed — Inngest's fan-out pattern means multiple functions can listen to the same event.

// src/inngest/functions/send-welcome-email.ts
import { inngest } from "@/inngest/client";
 
export const sendWelcomeEmail = inngest.createFunction(
  {
    id: "send-welcome-email",
    retries: 5,
    throttle: {
      limit: 10,
      period: "1m",
    },
  },
  { event: "user/signup.completed" },
  async ({ event, step }) => {
    // Step 1: Render email template
    const emailHtml = await step.run("render-template", async () => {
      return renderWelcomeTemplate({
        name: event.data.name,
        plan: event.data.plan,
      });
    });
 
    // Step 2: Send via email provider
    const result = await step.run("send-email", async () => {
      // In production, use Resend, SendGrid, etc.
      console.log(`Sending welcome email to ${event.data.email}`);
      return {
        messageId: `msg_${Date.now()}`,
        to: event.data.email,
        status: "sent",
      };
    });
 
    // Step 3: Log analytics event
    await step.run("track-analytics", async () => {
      console.log(`Tracking welcome email sent for ${event.data.userId}`);
      // posthog.capture('welcome_email_sent', { userId: event.data.userId })
    });
 
    return result;
  }
);
 
function renderWelcomeTemplate(data: { name: string; plan: string }) {
  return `
    <h1>Welcome to SaaS App, ${data.name}!</h1>
    <p>You are on the <strong>${data.plan}</strong> plan.</p>
    <p>Your 14-day trial starts now. Explore all features!</p>
  `;
}

Throttling

The throttle configuration limits this function to 10 executions per minute. This is critical when sending emails — you do not want to overwhelm your email provider's API or trigger rate limits during a traffic spike.

Step 6: Build the Trial Management Workflow

This is where Inngest really shines — a long-running workflow that spans days.

// src/inngest/functions/manage-trial.ts
import { inngest } from "@/inngest/client";
 
export const manageTrialWorkflow = inngest.createFunction(
  {
    id: "manage-trial-workflow",
    retries: 3,
    cancelOn: [
      {
        event: "billing/subscription.created",
        match: "data.userId",
      },
    ],
  },
  { event: "user/trial.started" },
  async ({ event, step }) => {
    const userId = event.data.userId;
    const trialEndDate = new Date(event.data.trialEndDate);
 
    // Step 1: Wait 7 days, then send mid-trial email
    await step.sleep("wait-for-day-7", "7 days");
 
    await step.run("send-mid-trial-email", async () => {
      console.log(`Sending mid-trial reminder to user ${userId}`);
      // Send email: "You're halfway through your trial!"
      return { sent: true, type: "mid-trial" };
    });
 
    // Step 2: Wait until day 13 (6 more days)
    await step.sleep("wait-for-day-13", "6 days");
 
    await step.run("send-trial-ending-email", async () => {
      console.log(`Sending trial-ending warning to user ${userId}`);
      // Send email: "Your trial ends tomorrow!"
      return { sent: true, type: "trial-ending" };
    });
 
    // Step 3: Wait for the final day
    await step.sleep("wait-for-trial-end", "1 day");
 
    // Step 4: Check if user converted
    const hasSubscription = await step.run("check-subscription", async () => {
      // Query database for active subscription
      console.log(`Checking if user ${userId} has converted`);
      return false; // Simulated — no subscription found
    });
 
    if (!hasSubscription) {
      // Step 5: Downgrade to free plan
      await step.run("downgrade-to-free", async () => {
        console.log(`Downgrading user ${userId} to free plan`);
        // Update user plan in database
        return { plan: "free" };
      });
 
      await step.run("send-downgrade-email", async () => {
        console.log(`Sending downgrade notification to user ${userId}`);
        return { sent: true, type: "downgraded" };
      });
    }
 
    return { userId, converted: hasSubscription };
  }
);

Key Concepts Here

step.sleep() — Pauses the function for the specified duration. The function is not consuming compute while sleeping. Inngest wakes it up after the period and resumes from the next step with all previous step results intact.

cancelOn — If a billing/subscription.created event arrives with a matching userId, Inngest automatically cancels this workflow. The user converted — no need to send trial-ending emails or downgrade them.

This single function replaces what would otherwise be a cron job, a queue, a state machine, and a database table tracking trial status. The entire workflow lives in one readable TypeScript function.

Step 7: Build the Scheduled Churn Report

Inngest supports cron-based scheduling for recurring tasks.

// src/inngest/functions/daily-churn-report.ts
import { inngest } from "@/inngest/client";
 
export const dailyChurnReport = inngest.createFunction(
  {
    id: "daily-churn-report",
  },
  { cron: "0 9 * * *" }, // Every day at 9:00 AM
  async ({ step }) => {
    // Step 1: Query at-risk users
    const atRiskUsers = await step.run("query-at-risk-users", async () => {
      console.log("Querying users with trials ending in 3 days...");
      // Database query for users whose trial ends within 3 days
      // and who haven't been active in the last 48 hours
      return [
        { userId: "user_1", email: "alice@example.com", trialEndsIn: 2 },
        { userId: "user_2", email: "bob@example.com", trialEndsIn: 1 },
      ];
    });
 
    if (atRiskUsers.length === 0) {
      return { report: "No at-risk users today" };
    }
 
    // Step 2: Compile report
    const report = await step.run("compile-report", async () => {
      return {
        date: new Date().toISOString(),
        atRiskCount: atRiskUsers.length,
        users: atRiskUsers,
        summary: `${atRiskUsers.length} users at risk of churning`,
      };
    });
 
    // Step 3: Send to Slack
    await step.run("notify-team", async () => {
      console.log(`Sending churn report to Slack: ${report.summary}`);
      // In production: post to Slack webhook
      return { notified: true };
    });
 
    return report;
  }
);

The cron trigger replaces the event trigger. This function runs every day at 9 AM regardless of any events. Steps still provide durability — if the Slack notification fails, only that step retries.

Step 8: Set Up the Dev Server

Start the Inngest Dev Server alongside your Next.js app:

npx inngest-cli@latest dev

In a separate terminal, start your Next.js app:

npm run dev

The Dev Server runs at http://localhost:8288 and automatically discovers your functions by calling your /api/inngest endpoint. Open the Dev Server dashboard to see all registered functions, send test events, and trace executions step by step.

Step 9: Trigger Events from Your Application

Now wire up your signup flow to send events to Inngest.

// src/app/api/auth/signup/route.ts
import { NextResponse } from "next/server";
import { inngest } from "@/inngest/client";
 
export async function POST(request: Request) {
  const body = await request.json();
  const { email, name, plan } = body;
 
  // Create user in your auth system (e.g., better-auth, next-auth)
  const userId = `user_${Date.now()}`;
 
  // Send the signup event — Inngest handles the rest
  await inngest.send({
    name: "user/signup.completed",
    data: {
      userId,
      email,
      name,
      plan: plan || "free",
    },
  });
 
  return NextResponse.json({ userId, status: "created" });
}

When this event fires, both provisionAccount and sendWelcomeEmail execute concurrently — fan-out with zero configuration. The provisioning function then emits user/trial.started, which triggers manageTrialWorkflow. One event, three functions, full pipeline.

Step 10: Event Coordination with step.waitForEvent()

Sometimes you need a function to pause and wait for an external event before continuing. This is event coordination — a powerful alternative to polling.

// src/inngest/functions/onboarding-with-verification.ts
import { inngest } from "@/inngest/client";
 
export const onboardingWithVerification = inngest.createFunction(
  {
    id: "onboarding-with-verification",
  },
  { event: "user/signup.completed" },
  async ({ event, step }) => {
    // Step 1: Send verification email
    await step.run("send-verification-email", async () => {
      console.log(`Sending verification email to ${event.data.email}`);
      return { sent: true };
    });
 
    // Step 2: Wait up to 24 hours for the user to verify
    const verificationEvent = await step.waitForEvent(
      "wait-for-email-verification",
      {
        event: "user/email.verified",
        match: "data.userId",
        timeout: "24h",
      }
    );
 
    if (!verificationEvent) {
      // Timeout — user never verified
      await step.run("send-reminder", async () => {
        console.log(`User ${event.data.userId} did not verify — sending reminder`);
        return { reminded: true };
      });
      return { status: "unverified", reminded: true };
    }
 
    // User verified — continue onboarding
    await step.run("activate-full-access", async () => {
      console.log(`User ${event.data.userId} verified — activating full access`);
      return { activated: true };
    });
 
    return { status: "verified", activated: true };
  }
);

step.waitForEvent() pauses the function until a matching event arrives or the timeout expires. The match field ensures it only resumes when the userId in the incoming event matches the original signup event. This pattern replaces webhook callbacks, polling loops, and state machines.

Step 11: Concurrency Control

When processing events at scale, you often need to limit how many function runs execute simultaneously — for example, to respect API rate limits.

// src/inngest/functions/sync-to-crm.ts
import { inngest } from "@/inngest/client";
 
export const syncToCRM = inngest.createFunction(
  {
    id: "sync-to-crm",
    concurrency: {
      limit: 5,
      key: "event.data.crmProvider",
    },
    retries: 10,
    backoff: {
      type: "exponential",
      minDelay: "1s",
      maxDelay: "5m",
    },
  },
  { event: "user/signup.completed" },
  async ({ event, step }) => {
    await step.run("push-to-crm", async () => {
      // Only 5 runs at a time per CRM provider
      console.log(`Syncing user ${event.data.userId} to CRM`);
      return { synced: true };
    });
  }
);

The concurrency configuration ensures at most 5 runs execute simultaneously per CRM provider. Combined with exponential backoff on retries, this setup handles rate limits and transient failures gracefully.

Step 12: Testing Your Functions

Inngest functions are just TypeScript — you can unit test them by mocking the step tools.

// src/inngest/functions/__tests__/provision-account.test.ts
import { describe, it, expect, vi } from "vitest";
 
describe("provisionAccount", () => {
  it("should create user and workspace", async () => {
    // You can test individual step logic by extracting it
    const mockEvent = {
      data: {
        userId: "test_user_1",
        email: "test@example.com",
        name: "Test User",
        plan: "free" as const,
      },
    };
 
    // Test the business logic directly
    const user = {
      id: mockEvent.data.userId,
      email: mockEvent.data.email,
      name: mockEvent.data.name,
      plan: mockEvent.data.plan,
      createdAt: expect.any(String),
    };
 
    expect(user.email).toBe("test@example.com");
    expect(user.plan).toBe("free");
  });
});

For integration testing, the Inngest Dev Server provides a test mode where you can send events and assert on function outputs programmatically.

Step 13: Deploy to Production

  1. Create an account at the Inngest website
  2. Get your signing key and event key from the dashboard
  3. Add environment variables:
# .env.production
INNGEST_SIGNING_KEY=signkey-prod-xxxxx
INNGEST_EVENT_KEY=eventkey-xxxxx
  1. Deploy your Next.js app to Vercel, Railway, or any platform
  2. Register your app URL in the Inngest dashboard

Inngest Cloud calls your /api/inngest endpoint to invoke functions — your app does not need to poll or maintain long-running connections.

Option B: Self-Hosted

Inngest is open source. You can run the Inngest server yourself:

docker run -p 8288:8288 inngest/inngest:latest

Set the INNGEST_BASE_URL environment variable to point your app at your self-hosted instance.

Step 14: Monitor and Debug

The Inngest dashboard (both cloud and Dev Server) provides:

  • Function list — all registered functions with their triggers and configuration
  • Run history — every execution with input events, step outputs, and timing
  • Step traces — visual timeline of each step within a run, including sleep durations
  • Error details — full stack traces with retry history
  • Event log — every event sent to the system with payload details

When debugging a failed run, you can see exactly which step failed, what data it received, and how many retries occurred. This level of observability eliminates the guesswork that plagues traditional queue-based systems.

Comparing Inngest to Alternatives

FeatureInngestTraditional Queue (BullMQ)Cron Jobs
Durable stepsYesManualNo
Event-drivenYesManual routingNo
Sleep for daysYes (no compute cost)Requires persistent workerTimer hacks
Retry with backoffBuilt-inManual configManual
Fan-outAutomaticManual routingN/A
Local devDev Server with UIRedis requiredcrontab
Type safetyFull TypeScriptPartialNone
ObservabilityBuilt-in dashboardExternal toolsLog files

Troubleshooting

Functions not appearing in Dev Server

Make sure your Next.js app is running and the /api/inngest route is accessible. The Dev Server discovers functions by calling GET /api/inngest. Check that all functions are imported and passed to serve().

Steps re-executing unexpectedly

Step results are memoized by their step ID (the first argument to step.run()). If you change a step ID, Inngest treats it as a new step. Keep step IDs stable across deployments.

Sleep not waking up in dev

The Inngest Dev Server processes sleeps in accelerated time for testing. If a sleep appears stuck, check the Dev Server logs. For very long sleeps in development, you can use shorter durations and adjust for production.

Type errors on events

Make sure your event types in the Inngest client match the payloads you are sending. The schemas configuration provides compile-time safety — use it to catch mismatches early.

Next Steps

  • Add middleware — Inngest supports function middleware for logging, authentication, and error reporting
  • Batch processing — use step.run() in a loop to process arrays of items with individual step durability
  • Multi-tenant concurrency — use concurrency keys to isolate rate limits per customer
  • Connect to real services — integrate Resend for email, Stripe for billing, and PostHog for analytics
  • Explore AI workflows — Inngest is popular for orchestrating multi-step AI pipelines with LLM calls, embeddings, and vector search

Conclusion

You have built a complete event-driven SaaS pipeline with Inngest and Next.js. Your application now handles user onboarding, trial management, email notifications, CRM sync, and churn reporting — all with durable execution, automatic retries, and zero infrastructure management.

The key takeaways:

  • Durable functions survive crashes and retries without re-executing completed steps
  • Event-driven fan-out lets multiple functions react to the same event independently
  • step.sleep() enables long-running workflows (days or weeks) without consuming compute
  • step.waitForEvent() replaces polling and webhook callbacks with declarative event coordination
  • Concurrency and throttling protect external APIs from being overwhelmed
  • The Dev Server gives you full observability during local development

Inngest turns complex, multi-step backend logic into readable TypeScript functions. Instead of stitching together queues, cron jobs, and state machines, you write the workflow as a single function and let Inngest handle the rest.


Want to read more tutorials? Check out our latest tutorial on 1 Laravel 11 Basics: PHP in 15 minutes.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build Production Background Jobs with Trigger.dev v3 and Next.js

Learn how to build reliable background jobs, scheduled tasks, and multi-step workflows using Trigger.dev v3 with Next.js. This tutorial covers task creation, error handling, retries, scheduled cron jobs, and deploying to production.

28 min read·