DEV Community

Ishan Jarwal
Ishan Jarwal

Posted on

Asynchronous Jobs made easy with Inngest | Learn how to setup Inngest in a Typescript Project

Inngest with Next.js and TypeScript: A Practical Guide to Background Jobs, Workflows, Retries, and Debugging

If you've ever written a Next.js API route that starts doing too much, you've probably run into this problem:

"The user doesn't actually need to wait for this."

Maybe you're sending an email after signup.

Maybe you're generating a PDF.

Maybe you're processing uploaded images.

Maybe you're syncing data with Stripe, Shopify, a CRM, or some third-party API.

Or maybe you've got a workflow that looks something like:

User signs up
   ↓
Create account
   ↓
Send welcome email
   ↓
Create CRM contact
   ↓
Wait 2 days
   ↓
Send follow-up email
Enter fullscreen mode Exit fullscreen mode

You can put all of this inside a regular Next.js request.

But eventually you'll start worrying about timeouts, retries, failed API calls, duplicated work, cron jobs, queues, workers, and what happens if your server dies halfway through the process.

That's where Inngest becomes useful.

Inngest gives you an event-driven way of running background jobs and durable workflows without having to build and maintain your own queue infrastructure. It provides things like retries, steps, delayed execution, concurrency controls, observability, and execution history.

This guide walks through setting it up from scratch in a TypeScript Next.js application and, more importantly, explains what each file is doing and why it exists.


What we're going to build

By the end, we'll have a Next.js application that looks roughly like this:

Next.js application
       │
       │ sends event
       ▼
   Inngest
       │
       │ triggers
       ▼
 Inngest Function
       │
       ├── Step 1
       ├── Step 2
       ├── Step 3
       └── Step 4
Enter fullscreen mode Exit fullscreen mode

We'll build a small example around a user signup.

When a user signs up, we'll send an event:

app/user.created
Enter fullscreen mode Exit fullscreen mode

Inngest will receive that event and trigger a function.

That function will:

  1. Fetch the user.
  2. Send a welcome email.
  3. Update some external system.
  4. Wait for a short period.
  5. Send a follow-up.

The individual pieces will be represented as Inngest steps, which gives us retryability and durable execution.


1. First, what problem is Inngest actually solving?

Before installing anything, it's worth understanding the mental model.

Suppose you have this API route:

export async function POST(request: Request) {
  const user = await createUser();

  await sendWelcomeEmail(user);

  await syncWithCRM(user);

  return Response.json({
    success: true,
  });
}
Enter fullscreen mode Exit fullscreen mode

It works.

But your HTTP request now depends on three things:

createUser
    ↓
sendWelcomeEmail
    ↓
syncWithCRM
Enter fullscreen mode Exit fullscreen mode

If syncWithCRM() takes 10 seconds, the request takes 10 seconds.

If the CRM is temporarily unavailable, the whole request can fail.

If the email succeeds but the CRM fails, you have to decide what happens when the client retries the original request.

That's where background execution starts becoming valuable.

With Inngest, your request can instead do this:

await inngest.send({
  name: "app/user.created",
  data: {
    userId: user.id,
  },
});
Enter fullscreen mode Exit fullscreen mode

The HTTP request can finish.

Inngest then takes over:

HTTP request
    │
    └── publish event
             │
             ▼
        Inngest
             │
             ▼
      user-created function
             │
             ├── fetch user
             ├── send email
             ├── sync CRM
             └── follow-up
Enter fullscreen mode Exit fullscreen mode

That's the basic idea.


2. When should you use Inngest?

A useful rule of thumb is:

If the user doesn't need to wait for the operation, it's a good candidate for Inngest.

Common examples include:

  • Sending emails
  • Processing uploaded files
  • Generating reports
  • Image/video processing
  • Webhook processing
  • Stripe-related background work
  • CRM synchronization
  • Notifications
  • Scheduled jobs
  • Data synchronization
  • AI workflows
  • Long-running processes
  • Multi-step workflows
  • Retryable API calls

Inngest functions can be triggered by events, schedules, and other workflow mechanisms. Steps can also pause, wait for events, invoke other functions, and retry independently.


3. Prerequisites

For this guide, I'm assuming:

  • Node.js is installed
  • You have a TypeScript Next.js project
  • You're using the Next.js App Router
  • Your project uses npm

For example:

Next.js
TypeScript
App Router
src/ directory
Enter fullscreen mode Exit fullscreen mode

If you're starting from scratch:

npx create-next-app@latest inngest-nextjs
Enter fullscreen mode Exit fullscreen mode

Then:

cd inngest-nextjs
Enter fullscreen mode Exit fullscreen mode

And start your application:

npm run dev
Enter fullscreen mode Exit fullscreen mode

4. Install Inngest

Inside your project:

npm install inngest
Enter fullscreen mode Exit fullscreen mode

That's all you need for the SDK.

The current Inngest Next.js quick start also uses:

npm install inngest
Enter fullscreen mode Exit fullscreen mode

for the TypeScript SDK.


5. The folder structure

Now let's organize the project.

There are many ways to structure Inngest code. You don't have to follow this exact structure, but this is a clean starting point:

src/
├── app/
│   ├── api/
│   │   └── inngest/
│   │       └── route.ts
│   │
│   └── page.tsx
│
├── inngest/
│   ├── client.ts
│   ├── functions/
│   │   ├── user-created.ts
│   │   └── index.ts
│   └── index.ts
│
└── lib/
    ├── email.ts
    ├── db.ts
    └── crm.ts
Enter fullscreen mode Exit fullscreen mode

You don't necessarily need all these files on day one.

The important pieces are:

inngest/client.ts
inngest/functions/*
app/api/inngest/route.ts
Enter fullscreen mode Exit fullscreen mode

Let's look at what each one does.


6. src/inngest/client.ts

This file creates your Inngest client.

Create:

src/inngest/client.ts
Enter fullscreen mode Exit fullscreen mode

Then:

import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-nextjs-app",
});
Enter fullscreen mode Exit fullscreen mode

That's it.

This client becomes the central object used to:

  • create Inngest functions
  • send events
  • configure your application identity
  • communicate with Inngest

Think of this file as:

"This is my application talking to Inngest."

The id should represent your application.

For example:

export const inngest = new Inngest({
  id: "acme-web",
});
Enter fullscreen mode Exit fullscreen mode

Keep the ID stable. Function IDs should also remain stable between deployments because Inngest uses them to identify functions.


7. Create your first Inngest function

Now let's make the interesting part.

Create:

src/inngest/functions/user-created.ts
Enter fullscreen mode Exit fullscreen mode

We'll start with something simple.

import { inngest } from "../client";

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
  },
  {
    event: "app/user.created",
  },
  async ({ event }) => {
    console.log("New user:", event.data.userId);

    return {
      success: true,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

The important pieces are:

inngest.createFunction(...)
Enter fullscreen mode Exit fullscreen mode

This creates the function.

Then:

id: "user-created"
Enter fullscreen mode Exit fullscreen mode

gives the function a stable identifier.

And:

event: "app/user.created"
Enter fullscreen mode Exit fullscreen mode

says:

"Run this function whenever an app/user.created event happens."

Inngest's current SDK documentation uses the v4 function configuration style with triggers, so the equivalent current form is:

import { inngest } from "../client";

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
    triggers: {
      event: "app/user.created",
    },
  },
  async ({ event }) => {
    console.log(event.data.userId);
  }
);
Enter fullscreen mode Exit fullscreen mode

Use the v4 form in new projects.


8. Export your functions

Create:

src/inngest/functions/index.ts
Enter fullscreen mode Exit fullscreen mode

Then:

export { userCreated } from "./user-created";
Enter fullscreen mode Exit fullscreen mode

This may seem unnecessary when you only have one function.

But imagine your application eventually has:

functions/
├── user-created.ts
├── send-welcome-email.ts
├── process-payment.ts
├── generate-report.ts
├── sync-crm.ts
└── cleanup-expired-data.ts
Enter fullscreen mode Exit fullscreen mode

Having one central export makes the route much cleaner.


9. Create the Inngest API route

This is the part that often confuses people.

Your Next.js application needs an HTTP endpoint that Inngest can communicate with.

Create:

src/app/api/inngest/route.ts
Enter fullscreen mode Exit fullscreen mode

Then:

import { serve } from "inngest/next";

import { inngest } from "@/inngest/client";
import { userCreated } from "@/inngest/functions";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [userCreated],
});
Enter fullscreen mode Exit fullscreen mode

If you have multiple functions:

import { serve } from "inngest/next";

import { inngest } from "@/inngest/client";
import {
  userCreated,
  sendWelcomeEmail,
  processPayment,
} from "@/inngest/functions";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [
    userCreated,
    sendWelcomeEmail,
    processPayment,
  ],
});
Enter fullscreen mode Exit fullscreen mode

This route is normally available at:

/api/inngest
Enter fullscreen mode Exit fullscreen mode

The serve() handler allows Inngest to discover your functions and invoke them through your application.

So conceptually:

Inngest
   │
   │ HTTP
   ▼
/api/inngest
   │
   ▼
Next.js
   │
   ├── userCreated
   ├── sendWelcomeEmail
   └── processPayment
Enter fullscreen mode Exit fullscreen mode

This endpoint is one of the most important pieces of the setup.


10. Your project should now look something like this

src/
├── app/
│   └── api/
│       └── inngest/
│           └── route.ts
│
└── inngest/
    ├── client.ts
    │
    └── functions/
        ├── index.ts
        └── user-created.ts
Enter fullscreen mode Exit fullscreen mode

And each file has a very specific responsibility:

File Responsibility
inngest/client.ts Creates the Inngest client
inngest/functions/*.ts Contains your actual background functions
inngest/functions/index.ts Exports all functions
app/api/inngest/route.ts Exposes the functions to Inngest

That's really the core architecture.


11. Run the Inngest Dev Server

This is where Inngest gets really nice during development.

You don't need to immediately create a cloud project just to test your function.

Inngest provides a local Dev Server that lets you:

  • discover your functions
  • send events
  • inspect runs
  • inspect errors
  • view step execution
  • rerun functions
  • cancel runs
  • debug workflows

The current Dev Server runs on port 8288 by default.

You can start it with:

npx inngest-cli@latest dev
Enter fullscreen mode Exit fullscreen mode

Or install the CLI and run:

inngest dev
Enter fullscreen mode Exit fullscreen mode

Then open:

http://localhost:8288
Enter fullscreen mode Exit fullscreen mode

You should see the Inngest development UI.


12. Run Next.js in development mode

In another terminal:

npm run dev
Enter fullscreen mode Exit fullscreen mode

For the current SDK, you can explicitly tell Inngest that you're working locally:

INNGEST_DEV=1
Enter fullscreen mode Exit fullscreen mode

Put that in:

.env.local
Enter fullscreen mode Exit fullscreen mode

So your .env.local might contain:

INNGEST_DEV=1
Enter fullscreen mode Exit fullscreen mode

In Dev Mode, the SDK communicates with your local Inngest Dev Server rather than Inngest Cloud. Signing keys aren't required for the local Dev Server.


13. Trigger your first event

Now we need to actually send:

app/user.created
Enter fullscreen mode Exit fullscreen mode

There are several ways to do this.

The simplest is from your server-side application code.

For example:

import { inngest } from "@/inngest/client";

await inngest.send({
  name: "app/user.created",
  data: {
    userId: "user_123",
  },
});
Enter fullscreen mode Exit fullscreen mode

That's the event.

Notice the event structure:

{
  name: "app/user.created",
  data: {
    userId: "user_123"
  }
}
Enter fullscreen mode Exit fullscreen mode

The name is what determines which functions are triggered.

The data is the payload your function receives.

Inside your function:

async ({ event }) => {
  console.log(event.data.userId);
}
Enter fullscreen mode Exit fullscreen mode

you'll get:

user_123
Enter fullscreen mode Exit fullscreen mode

Event keys are required for sending events to Inngest Cloud in production, but they're not required when using the local Dev Server.


14. A more realistic example

Let's make this useful.

Suppose a user registers.

Our application creates the user:

const user = await db.user.create({
  data: {
    email,
    name,
  },
});
Enter fullscreen mode Exit fullscreen mode

Then we don't want to make the HTTP request wait for everything else.

So:

await inngest.send({
  name: "app/user.created",
  data: {
    userId: user.id,
  },
});
Enter fullscreen mode Exit fullscreen mode

Now our Inngest function can handle the rest.

import { inngest } from "../client";

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
  },
  {
    event: "app/user.created",
  },
  async ({ event, step }) => {
    const user = await step.run("get-user", async () => {
      // Fetch the user from your database
      return getUser(event.data.userId);
    });

    await step.run("send-welcome-email", async () => {
      await sendWelcomeEmail(user);
    });

    await step.run("sync-crm", async () => {
      await syncUserWithCRM(user);
    });

    return {
      userId: user.id,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

The current v4 syntax is:

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
    triggers: {
      event: "app/user.created",
    },
  },
  async ({ event, step }) => {
    // ...
  }
);
Enter fullscreen mode Exit fullscreen mode

Again, use this v4 form for a new project.


15. Why step.run() matters

This is probably the most important Inngest concept to understand.

You might initially write:

await sendWelcomeEmail(user);

await syncCRM(user);
Enter fullscreen mode Exit fullscreen mode

But with Inngest, you'd normally structure meaningful side effects as steps:

await step.run("send-welcome-email", async () => {
  await sendWelcomeEmail(user);
});

await step.run("sync-crm", async () => {
  await syncCRM(user);
});
Enter fullscreen mode Exit fullscreen mode

Why?

Because steps are independently tracked and retried.

If this happens:

get-user        ✓
send-email      ✓
sync-crm        ✗
Enter fullscreen mode Exit fullscreen mode

Inngest doesn't need to blindly start the whole function from scratch.

The completed work is checkpointed, and the failed step can be retried.

This is one of the core ideas behind Inngest's durable execution model.


16. Don't put important side effects outside steps

This is a very important practical rule.

Avoid:

await sendEmail();

await step.run("save-user", async () => {
  await db.user.update(...);
});
Enter fullscreen mode Exit fullscreen mode

If your function is replayed, the email operation isn't represented as an Inngest step.

Prefer:

await step.run("send-email", async () => {
  await sendEmail();
});

await step.run("save-user", async () => {
  await db.user.update(...);
});
Enter fullscreen mode Exit fullscreen mode

Database writes, API calls, emails, payment operations, and similar side effects are usually good candidates for step.run().

Inngest specifically recommends putting nondeterministic side effects such as database writes and API calls inside steps so completed work can be checkpointed.


17. Retries

One of the nicest things about steps is retry behavior.

Imagine:

await step.run("send-email", async () => {
  await emailProvider.send(...);
});
Enter fullscreen mode Exit fullscreen mode

If your email provider temporarily returns an error, the step can be retried according to your function's retry configuration.

Each step has its own retry counter.

That's much more useful than writing this everywhere:

try {
  await something();
} catch {
  await something();
}
Enter fullscreen mode Exit fullscreen mode

You don't want retry logic scattered throughout your application.

Let your workflow layer handle it.


18. Sleeping without keeping a server request open

This is another area where Inngest becomes particularly useful.

Imagine:

User signs up
      ↓
Send welcome email
      ↓
Wait 3 days
      ↓
Send onboarding email
Enter fullscreen mode Exit fullscreen mode

You don't want to keep a Node.js process sitting around for three days.

With Inngest:

await step.sleep("wait-before-follow-up", "3 days");
Enter fullscreen mode Exit fullscreen mode

Then:

await step.run("send-follow-up", async () => {
  await sendFollowUpEmail();
});
Enter fullscreen mode Exit fullscreen mode

The function can pause and resume later rather than keeping a normal HTTP request alive.

The SDK provides step APIs including sleep, sleepUntil, waitForEvent, invoke, and sendEvent.


19. A complete workflow example

Here's what a slightly more realistic onboarding workflow could look like:

import { inngest } from "../client";

export const onboardingWorkflow = inngest.createFunction(
  {
    id: "user-onboarding",
  },
  {
    event: "app/user.created",
  },
  async ({ event, step }) => {
    const user = await step.run("get-user", async () => {
      return getUser(event.data.userId);
    });

    await step.run("send-welcome-email", async () => {
      await sendWelcomeEmail(user);
    });

    await step.run("create-crm-contact", async () => {
      await createCRMContact(user);
    });

    await step.sleep(
      "wait-before-onboarding-email",
      "3 days"
    );

    await step.run("send-onboarding-email", async () => {
      await sendOnboardingEmail(user);
    });

    return {
      success: true,
      userId: user.id,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

Now your workflow is readable almost like a checklist:

Get user
   ↓
Send welcome email
   ↓
Create CRM contact
   ↓
Wait 3 days
   ↓
Send onboarding email
Enter fullscreen mode Exit fullscreen mode

And that's really one of the strengths of Inngest.

The code describes the workflow instead of forcing you to build your own queue/state machine infrastructure.


20. Environment variables

There are a few Inngest environment variables worth knowing.

For local development:

INNGEST_DEV=1
Enter fullscreen mode Exit fullscreen mode

For production:

INNGEST_EVENT_KEY=...
INNGEST_SIGNING_KEY=...
Enter fullscreen mode Exit fullscreen mode

Potentially:

INNGEST_SIGNING_KEY_FALLBACK=...
Enter fullscreen mode Exit fullscreen mode

during signing-key rotation.

There are also configuration variables such as:

INNGEST_BASE_URL=...
INNGEST_ENV=...
INNGEST_LOG_LEVEL=...
INNGEST_SERVE_ORIGIN=...
INNGEST_SERVE_PATH=...
INNGEST_STREAMING=...
Enter fullscreen mode Exit fullscreen mode

Not every application needs all of these.

A typical production setup might look like:

INNGEST_EVENT_KEY=your-event-key
INNGEST_SIGNING_KEY=your-signing-key
Enter fullscreen mode Exit fullscreen mode

Keep these secrets out of Git.

Don't do this:

const inngest = new Inngest({
  id: "my-app",
  signingKey: "super-secret-key",
});
Enter fullscreen mode Exit fullscreen mode

Prefer environment variables.

Inngest explicitly recommends using INNGEST_SIGNING_KEY instead of hard-coding the signing key.


21. Event Key vs Signing Key

These two are easy to mix up.

Event Key

An Event Key is used by your application to send events to Inngest.

For example:

await inngest.send({
  name: "app/user.created",
  data: {
    userId: "123",
  },
});
Enter fullscreen mode Exit fullscreen mode

In production, the SDK can use:

INNGEST_EVENT_KEY=...
Enter fullscreen mode Exit fullscreen mode

Event keys are environment-specific.

Signing Key

The Signing Key is about securely communicating between your deployed application and Inngest.

INNGEST_SIGNING_KEY=...
Enter fullscreen mode Exit fullscreen mode

In other words:

EVENT KEY
   ↓
"My application can send events"

SIGNING KEY
   ↓
"Secure communication between Inngest and my app"
Enter fullscreen mode Exit fullscreen mode

Don't confuse the two.


22. What happens in production?

Locally you have:

Next.js
localhost:3000

       ↕

Inngest Dev Server
localhost:8288
Enter fullscreen mode Exit fullscreen mode

In production, the architecture is closer to:

Your Next.js deployment
        │
        │ /api/inngest
        │
        ▼
   Inngest Cloud
        │
        │
        ▼
  Function execution
Enter fullscreen mode Exit fullscreen mode

For serverless platforms such as Vercel, the normal approach is to expose the serve() handler through your application at /api/inngest.


23. Deploying with Vercel

If you're using Next.js, Vercel is probably the deployment platform you'll encounter most often.

Your application already has:

/api/inngest
Enter fullscreen mode Exit fullscreen mode

So after deploying the application, Inngest needs to be able to reach that endpoint.

For production, configure:

INNGEST_EVENT_KEY=...
INNGEST_SIGNING_KEY=...
Enter fullscreen mode Exit fullscreen mode

in your production environment.

Inngest also provides a Vercel integration that can help configure the necessary integration.

The exact deployment workflow can change depending on your Vercel/Inngest setup, so when deploying a real application, it's worth checking the current Inngest deployment documentation rather than blindly copying an old blog post.


24. Inngest environments

This is something you should understand before your project becomes a team project.

Inngest supports separate environments, including:

  • Production
  • Branch environments
  • Custom environments
  • Local development

The point is isolation.

You don't want your local testing to accidentally trigger production workflows.

Each environment has isolated event/function data and its own keys.

A typical team setup might look like:

Local
  ↓
Dev Server

Feature branch
  ↓
Branch environment

Staging
  ↓
Custom environment

Production
  ↓
Production environment
Enter fullscreen mode Exit fullscreen mode

If you're working alone, you can start with:

Local → Production
Enter fullscreen mode Exit fullscreen mode

and introduce additional environments when you actually need them.


25. The Inngest Portal / Dashboard

Once you're working with Inngest Cloud, the dashboard becomes your control room.

You'll typically use it to inspect:

  • Applications
  • Functions
  • Events
  • Function runs
  • Errors
  • Retries
  • Execution traces
  • Environments

The important thing to remember is that the dashboard isn't just a place to see whether something succeeded.

It's where you can investigate why it succeeded or failed.

Inngest's run view provides information about the event payload, execution timeline, steps, timings, errors, and retries.


26. Debugging locally

When something doesn't work, don't immediately start adding random console.log() calls everywhere.

First open:

http://localhost:8288
Enter fullscreen mode Exit fullscreen mode

and look at the run.

You should be able to see:

Function
   ↓
Run
   ↓
Step 1
   ↓
Step 2
   ↓
Step 3
Enter fullscreen mode Exit fullscreen mode

If a step fails, inspect that step.

For example:

user-created

✓ get-user
✓ send-welcome-email
✗ sync-crm
Enter fullscreen mode Exit fullscreen mode

Now you know exactly where the problem is.

That's much easier than looking at a giant application log.


27. Debugging production failures

Suppose you get this:

sync-crm
FAILED
Enter fullscreen mode Exit fullscreen mode

Go into the Inngest dashboard and open the run.

You can inspect the execution timeline and the failed step.

The dashboard can show individual attempts and the errors associated with them. It also provides options for rerunning runs and, in some cases, replaying from a step.

That gives you a workflow like:

Production failure
      ↓
Open Inngest
      ↓
Find failed run
      ↓
Open trace
      ↓
Find failed step
      ↓
Read error
      ↓
Fix code
      ↓
Deploy
      ↓
Rerun
Enter fullscreen mode Exit fullscreen mode

That's a much nicer debugging experience than manually reconstructing what happened from logs.


28. Use meaningful step IDs

This:

await step.run("step-1", async () => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

works.

But this:

await step.run("create-crm-contact", async () => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

is much better.

When you open the dashboard later, you immediately understand what's happening.

Good:

fetch-user
send-welcome-email
create-crm-contact
update-subscription
generate-invoice
send-invoice
Enter fullscreen mode Exit fullscreen mode

Bad:

step-1
step-2
step-3
step-4
Enter fullscreen mode Exit fullscreen mode

The step ID is also part of how Inngest identifies and memoizes the step, so don't casually change IDs between versions.


29. Logging

You can still use normal logging:

console.log("Processing user:", event.data.userId);
Enter fullscreen mode Exit fullscreen mode

But inside an Inngest function you can also use the provided logger:

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
  },
  {
    event: "app/user.created",
  },
  async ({ event, logger }) => {
    logger.info("Processing user", {
      userId: event.data.userId,
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

The SDK exposes logging interfaces such as info, warn, error, and debug.

Keep logs useful.

Instead of:

console.log("here");
Enter fullscreen mode Exit fullscreen mode

prefer:

logger.info("Starting CRM synchronization", {
  userId,
});
Enter fullscreen mode Exit fullscreen mode

Six months later, you'll thank yourself.


30. A note about retries and duplicate side effects

Retries are great.

But retries also mean you need to think about idempotency.

For example:

await step.run("charge-customer", async () => {
  await stripe.charges.create(...);
});
Enter fullscreen mode Exit fullscreen mode

You don't want a retry to accidentally charge somebody twice.

The same idea applies to:

  • payments
  • sending emails
  • creating external records
  • sending notifications
  • database mutations
  • webhook handling

Whenever an operation isn't naturally idempotent, use an idempotency key or design the operation so repeating it is safe.

For example:

await step.run("create-crm-contact", async () => {
  return crm.contacts.create({
    externalId: user.id,
    email: user.email,
  });
});
Enter fullscreen mode Exit fullscreen mode

Your CRM integration might then use externalId to avoid creating duplicate contacts.

This isn't an Inngest-specific problem.

It's simply something you need to think about whenever work can be retried.


31. Don't make one giant step

You could write:

await step.run("everything", async () => {
  await sendEmail();
  await syncCRM();
  await generateReport();
  await notifySlack();
});
Enter fullscreen mode Exit fullscreen mode

But now Inngest sees this as one unit of work.

If the Slack notification fails, the whole block may need to run again.

Instead:

await step.run("send-email", async () => {
  await sendEmail();
});

await step.run("sync-crm", async () => {
  await syncCRM();
});

await step.run("generate-report", async () => {
  await generateReport();
});

await step.run("notify-slack", async () => {
  await notifySlack();
});
Enter fullscreen mode Exit fullscreen mode

Now you get a much clearer workflow:

send-email        ✓
sync-crm          ✓
generate-report   ✓
notify-slack      ✗
Enter fullscreen mode Exit fullscreen mode

That's exactly the kind of execution history you want when debugging.


32. But don't create a step for every line either

The opposite extreme is also unnecessary.

You don't need:

await step.run("create-variable", () => {
  return "hello";
});

await step.run("uppercase-variable", () => {
  return value.toUpperCase();
});

await step.run("log-variable", () => {
  console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

Steps are most useful around meaningful units of work.

A good step is usually something like:

fetch data
write database
call external API
send email
generate document
charge payment
Enter fullscreen mode Exit fullscreen mode

Think in terms of units that you would want to retry or inspect independently.


33. Sending multiple events

Your application can publish events whenever something interesting happens.

For example:

await inngest.send({
  name: "app/order.created",
  data: {
    orderId: order.id,
  },
});
Enter fullscreen mode Exit fullscreen mode

Then a function can listen:

export const processOrder = inngest.createFunction(
  {
    id: "process-order",
  },
  {
    event: "app/order.created",
  },
  async ({ event, step }) => {
    // process order
  }
);
Enter fullscreen mode Exit fullscreen mode

This gives you a useful architecture:

Business event
      │
      ▼
Inngest
      │
      ├── send confirmation email
      │
      ├── update analytics
      │
      ├── sync inventory
      │
      └── notify fulfillment
Enter fullscreen mode Exit fullscreen mode

Different functions can react to the same event.

That helps keep your application loosely coupled.


34. Event naming conventions

Don't randomly name events.

Pick a convention and stick to it.

For example:

app/user.created
app/user.deleted
app/order.created
app/order.paid
app/order.cancelled
app/subscription.created
app/subscription.cancelled
Enter fullscreen mode Exit fullscreen mode

A nice pattern is:

domain/entity.action
Enter fullscreen mode Exit fullscreen mode

For example:

app/user.created
Enter fullscreen mode Exit fullscreen mode

rather than:

newUser
Enter fullscreen mode Exit fullscreen mode

It becomes much easier to understand as the number of events grows.


35. Cron jobs

Inngest isn't only for events.

Scheduled functions are another common use case.

For example, you might want to:

Every day at 2 AM
      ↓
Find expired subscriptions
      ↓
Update database
      ↓
Send notifications
Enter fullscreen mode Exit fullscreen mode

The function can be configured with a cron trigger.

This is particularly useful when you have scheduled tasks that would otherwise require separate cron infrastructure.

Inngest's function model supports cron/background jobs as well as event-driven workflows.


36. Concurrency and throttling

As your application grows, you'll eventually run into another problem:

"What happens if 10,000 events arrive at once?"

You don't necessarily want 10,000 copies of the same function hammering your API or database.

Inngest supports flow-control features such as:

  • concurrency
  • throttling
  • rate limiting
  • debouncing
  • prioritization

For example, a function can be configured with a throttle:

{
  id: "sync-systems",
  triggers: {
    event: "app/sync.requested",
  },
  throttle: {
    limit: 3,
    period: "1m",
  },
}
Enter fullscreen mode Exit fullscreen mode

The exact configuration should be chosen based on the limits of the systems you're calling.

Inngest documents these flow-control features as part of its function model.


37. Testing Inngest functions

Don't rely exclusively on the Dev Server.

You should also test your functions in your automated test suite.

Inngest provides testing utilities for executing functions and inspecting step state.

For example, its testing API can expose the state/results of individual steps, which makes it possible to test both successful and failing workflows.

This becomes particularly valuable for workflows containing business-critical logic.

A useful testing strategy is:

Unit tests
   ↓
Individual business logic

Inngest tests
   ↓
Workflow behavior

Dev Server
   ↓
Manual integration testing

Production dashboard
   ↓
Real execution monitoring
Enter fullscreen mode Exit fullscreen mode

38. Keep business logic separate from Inngest

This is another architectural choice that pays off later.

Instead of putting everything directly inside:

inngest.createFunction(...)
Enter fullscreen mode Exit fullscreen mode

you can keep business logic elsewhere.

For example:

src/
├── inngest/
│   └── functions/
│       └── user-created.ts
│
└── services/
    ├── users.ts
    ├── email.ts
    └── crm.ts
Enter fullscreen mode Exit fullscreen mode

Then your function becomes:

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
    triggers: {
      event: "app/user.created",
    },
  },
  async ({ event, step }) => {
    const user = await step.run("get-user", () =>
      userService.getById(event.data.userId)
    );

    await step.run("send-email", () =>
      emailService.sendWelcome(user)
    );

    await step.run("sync-crm", () =>
      crmService.syncUser(user)
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

That's a much healthier separation.

Inngest describes when and how the work runs.

Your services describe what the work actually does.


39. A clean final folder structure

For a medium-sized project, I would aim for something like:

src/
│
├── app/
│   ├── api/
│   │   └── inngest/
│   │       └── route.ts
│   │
│   ├── dashboard/
│   └── ...
│
├── inngest/
│   ├── client.ts
│   │
│   └── functions/
│       ├── index.ts
│       ├── user-created.ts
│       ├── order-created.ts
│       ├── process-payment.ts
│       └── daily-cleanup.ts
│
├── services/
│   ├── email.ts
│   ├── crm.ts
│   ├── payments.ts
│   └── users.ts
│
├── db/
│   └── ...
│
└── lib/
    └── ...
Enter fullscreen mode Exit fullscreen mode

And conceptually:

API / Server Action
        │
        │ inngest.send()
        ▼
     Event
        │
        ▼
    Function
        │
        ├── Step
        ├── Step
        ├── Step
        └── Step
             │
             ▼
          Services
             │
             ├── Database
             ├── Email
             ├── CRM
             └── APIs
Enter fullscreen mode Exit fullscreen mode

40. A complete example

Let's put the pieces together.

src/inngest/client.ts

import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-nextjs-app",
});
Enter fullscreen mode Exit fullscreen mode

src/inngest/functions/user-created.ts

import { inngest } from "../client";

export const userCreated = inngest.createFunction(
  {
    id: "user-created",
    name: "User Created",
    triggers: {
      event: "app/user.created",
    },
  },
  async ({ event, step }) => {
    const user = await step.run("get-user", async () => {
      // Replace with your real database call.
      return {
        id: event.data.userId,
        email: "user@example.com",
      };
    });

    await step.run("send-welcome-email", async () => {
      console.log(
        `Sending welcome email to ${user.email}`
      );

      // await emailService.sendWelcomeEmail(user);
    });

    await step.run("sync-crm", async () => {
      console.log(
        `Syncing ${user.id} with CRM`
      );

      // await crmService.createOrUpdateUser(user);
    });

    return {
      success: true,
      userId: user.id,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

src/inngest/functions/index.ts

export { userCreated } from "./user-created";
Enter fullscreen mode Exit fullscreen mode

src/app/api/inngest/route.ts

import { serve } from "inngest/next";

import { inngest } from "@/inngest/client";
import { userCreated } from "@/inngest/functions";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [userCreated],
});
Enter fullscreen mode Exit fullscreen mode

.env.local

INNGEST_DEV=1
Enter fullscreen mode Exit fullscreen mode

Then start two terminals.

Terminal 1:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Terminal 2:

npx inngest-cli@latest dev
Enter fullscreen mode Exit fullscreen mode

Then open:

http://localhost:8288
Enter fullscreen mode Exit fullscreen mode

Trigger:

await inngest.send({
  name: "app/user.created",
  data: {
    userId: "user_123",
  },
});
Enter fullscreen mode Exit fullscreen mode

And you should see the run appear in the Dev Server.


41. The mental model I recommend

If you're new to Inngest, don't try to memorize every API.

Just remember these five things:

1. Event

Something happened.

await inngest.send({
  name: "app/order.created",
  data: {
    orderId,
  },
});
Enter fullscreen mode Exit fullscreen mode

2. Function

Something should react to that event.

inngest.createFunction(...)
Enter fullscreen mode Exit fullscreen mode

3. Step

This is a meaningful unit of work.

await step.run("send-email", async () => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

4. Serve

This exposes your functions to Inngest.

serve({
  client: inngest,
  functions: [...],
});
Enter fullscreen mode Exit fullscreen mode

5. Dev Server / Dashboard

This is where you inspect what actually happened.

localhost:8288
Enter fullscreen mode Exit fullscreen mode

That's most of the foundation.


42. Common mistakes

Mistake #1: Forgetting the /api/inngest route

You created your function:

inngest.createFunction(...)
Enter fullscreen mode Exit fullscreen mode

but forgot:

app/api/inngest/route.ts
Enter fullscreen mode Exit fullscreen mode

Inngest needs a way to discover and invoke your functions.


Mistake #2: Running the wrong environment

If you're developing locally, make sure your SDK is actually in Dev Mode:

INNGEST_DEV=1
Enter fullscreen mode Exit fullscreen mode

Otherwise the SDK defaults to cloud mode unless configured otherwise.


Mistake #3: Hard-coding secrets

Don't commit:

signingKey: "..."
Enter fullscreen mode Exit fullscreen mode

Use:

INNGEST_SIGNING_KEY=...
Enter fullscreen mode Exit fullscreen mode

instead.


Mistake #4: Putting side effects outside steps

Instead of:

await chargeCustomer();

await step.run("save-order", ...);
Enter fullscreen mode Exit fullscreen mode

prefer:

await step.run("charge-customer", async () => {
  await chargeCustomer();
});

await step.run("save-order", async () => {
  await saveOrder();
});
Enter fullscreen mode Exit fullscreen mode

Mistake #5: Making one enormous step

Avoid:

await step.run("do-everything", async () => {
  // 500 lines
});
Enter fullscreen mode Exit fullscreen mode

Break meaningful operations into separate steps.


Mistake #6: Ignoring idempotency

If something can be retried, ask:

"What happens if this runs twice?"

Especially for:

  • payments
  • emails
  • database mutations
  • external APIs

Mistake #7: Using meaningless function IDs

Don't constantly change:

id: "user-created"
Enter fullscreen mode Exit fullscreen mode

to:

id: "user-created-v2"
Enter fullscreen mode Exit fullscreen mode

unless you actually intend to create a different Inngest function.

Stable IDs matter to Inngest's function identity and execution model.


43. What I'd do next in a real project

Once the basic setup works, I'd add things in roughly this order:

Step 1 — Create a proper event naming convention

For example:

app/user.created
app/user.deleted
app/order.created
app/order.paid
app/order.refunded
Enter fullscreen mode Exit fullscreen mode

Step 2 — Move business logic into services

Keep Inngest functions relatively small.

Step 3 — Add retries

Use retries for operations that can temporarily fail.

Step 4 — Add idempotency

Especially around payments and external APIs.

Step 5 — Add structured logging

Make production debugging easier.

Step 6 — Add concurrency controls

Particularly for expensive workloads.

Step 7 — Add automated tests

Test important workflows before they become difficult to reason about.

Step 8 — Add staging/custom environments

Once multiple people or deployments are involved.

Step 9 — Set up production monitoring

Learn to use the Inngest dashboard rather than relying only on application logs.

Step 10 — Consider more advanced workflow features

Once the basics feel comfortable, look at:

  • cron jobs
  • delayed execution
  • step.sleep()
  • step.waitForEvent()
  • step.invoke()
  • batching
  • concurrency
  • throttling
  • rate limiting
  • cancellation
  • retries
  • metadata
  • durable endpoints

44. A note about the Inngest portal

When you move beyond local development, don't think of the Inngest dashboard as just another admin page.

It becomes part of your operational workflow.

A typical debugging session might look like this:

Customer reports something went wrong
              ↓
Find the relevant function
              ↓
Find the run
              ↓
Inspect event payload
              ↓
Open execution trace
              ↓
Find failed step
              ↓
Inspect retry attempts
              ↓
Fix application
              ↓
Deploy
              ↓
Rerun the function
Enter fullscreen mode Exit fullscreen mode

The dashboard's run details are designed specifically for this kind of investigation.

And because Inngest automatically traces function runs, you don't need to build your own complete workflow tracing system just to understand what happened.


45. One last architectural picture

If you remember nothing else from this guide, remember this:

                    NEXT.JS
                       │
             ┌─────────┴─────────┐
             │                   │
          Request             Event
             │                   │
             │             inngest.send()
             │                   │
             ▼                   ▼
          Response            INNGEST
                                 │
                     ┌───────────┼───────────┐
                     │           │           │
                     ▼           ▼           ▼
                  Function    Function    Function
                     │
                ┌────┴────┐
                │         │
              Step       Step
                │         │
                ▼         ▼
              DB/API   Email/CRM
Enter fullscreen mode Exit fullscreen mode

Next.js handles your application.

Inngest handles the durable background execution.

Your functions describe workflows.

Steps divide those workflows into retryable units.

And the dashboard lets you see what actually happened.


46. Final checklist

Before calling your Inngest setup complete, check these:

  • [ ] inngest is installed
  • [ ] src/inngest/client.ts exists
  • [ ] Functions live under src/inngest/functions/
  • [ ] Every function has a stable ID
  • [ ] Functions have event triggers
  • [ ] /api/inngest exists
  • [ ] serve() includes all your functions
  • [ ] INNGEST_DEV=1 is configured for local development
  • [ ] Inngest Dev Server is running
  • [ ] You can open localhost:8288
  • [ ] You can send a test event
  • [ ] The function appears in the Dev Server
  • [ ] You can inspect a successful run
  • [ ] You can intentionally trigger an error and inspect the failure
  • [ ] Important side effects are inside step.run()
  • [ ] Production has an Event Key
  • [ ] Production has a Signing Key
  • [ ] Secrets aren't committed to Git
  • [ ] You have considered idempotency
  • [ ] You have considered retries
  • [ ] You have a plan for production monitoring

Conclusion

The nice thing about Inngest is that you don't have to think of it as "another queue system."

A better way to think about it is:

Inngest lets you describe reliable background work as code.

You publish an event:

await inngest.send({
  name: "app/user.created",
  data: {
    userId,
  },
});
Enter fullscreen mode Exit fullscreen mode

A function reacts:

inngest.createFunction(...)
Enter fullscreen mode Exit fullscreen mode

And the actual work is broken into durable steps:

await step.run("send-email", async () => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

That simple model scales surprisingly far.

You can start with one function that sends an email and eventually end up with workflows involving retries, delays, scheduled jobs, external APIs, database operations, concurrency controls, and multiple environments — without having to build the underlying queue and workflow infrastructure yourself.

The most important thing is not to rush into every advanced Inngest feature.

Start small.

Get this working:

Event
  ↓
Function
  ↓
Step
  ↓
Result
Enter fullscreen mode Exit fullscreen mode

Then open the Dev Server, break something deliberately, watch the failed run, fix it, and rerun it.

Once that workflow makes sense, the rest of Inngest becomes considerably easier to understand.

For the latest SDK details, especially around v4 APIs and deployment configuration, keep the official Inngest documentation nearby because the platform and SDK continue to evolve.

Top comments (0)