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
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
We'll build a small example around a user signup.
When a user signs up, we'll send an event:
app/user.created
Inngest will receive that event and trigger a function.
That function will:
- Fetch the user.
- Send a welcome email.
- Update some external system.
- Wait for a short period.
- 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,
});
}
It works.
But your HTTP request now depends on three things:
createUser
↓
sendWelcomeEmail
↓
syncWithCRM
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,
},
});
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
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
If you're starting from scratch:
npx create-next-app@latest inngest-nextjs
Then:
cd inngest-nextjs
And start your application:
npm run dev
4. Install Inngest
Inside your project:
npm install inngest
That's all you need for the SDK.
The current Inngest Next.js quick start also uses:
npm install inngest
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
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
Let's look at what each one does.
6. src/inngest/client.ts
This file creates your Inngest client.
Create:
src/inngest/client.ts
Then:
import { Inngest } from "inngest";
export const inngest = new Inngest({
id: "my-nextjs-app",
});
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",
});
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
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,
};
}
);
The important pieces are:
inngest.createFunction(...)
This creates the function.
Then:
id: "user-created"
gives the function a stable identifier.
And:
event: "app/user.created"
says:
"Run this function whenever an
app/user.createdevent 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);
}
);
Use the v4 form in new projects.
8. Export your functions
Create:
src/inngest/functions/index.ts
Then:
export { userCreated } from "./user-created";
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
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
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],
});
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,
],
});
This route is normally available at:
/api/inngest
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
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
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
Or install the CLI and run:
inngest dev
Then open:
http://localhost:8288
You should see the Inngest development UI.
12. Run Next.js in development mode
In another terminal:
npm run dev
For the current SDK, you can explicitly tell Inngest that you're working locally:
INNGEST_DEV=1
Put that in:
.env.local
So your .env.local might contain:
INNGEST_DEV=1
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
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",
},
});
That's the event.
Notice the event structure:
{
name: "app/user.created",
data: {
userId: "user_123"
}
}
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);
}
you'll get:
user_123
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,
},
});
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,
},
});
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,
};
}
);
The current v4 syntax is:
export const userCreated = inngest.createFunction(
{
id: "user-created",
triggers: {
event: "app/user.created",
},
},
async ({ event, step }) => {
// ...
}
);
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);
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);
});
Why?
Because steps are independently tracked and retried.
If this happens:
get-user ✓
send-email ✓
sync-crm ✗
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(...);
});
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(...);
});
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(...);
});
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();
}
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
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");
Then:
await step.run("send-follow-up", async () => {
await sendFollowUpEmail();
});
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,
};
}
);
Now your workflow is readable almost like a checklist:
Get user
↓
Send welcome email
↓
Create CRM contact
↓
Wait 3 days
↓
Send onboarding email
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
For production:
INNGEST_EVENT_KEY=...
INNGEST_SIGNING_KEY=...
Potentially:
INNGEST_SIGNING_KEY_FALLBACK=...
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=...
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
Keep these secrets out of Git.
Don't do this:
const inngest = new Inngest({
id: "my-app",
signingKey: "super-secret-key",
});
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",
},
});
In production, the SDK can use:
INNGEST_EVENT_KEY=...
Event keys are environment-specific.
Signing Key
The Signing Key is about securely communicating between your deployed application and Inngest.
INNGEST_SIGNING_KEY=...
In other words:
EVENT KEY
↓
"My application can send events"
SIGNING KEY
↓
"Secure communication between Inngest and my app"
Don't confuse the two.
22. What happens in production?
Locally you have:
Next.js
localhost:3000
↕
Inngest Dev Server
localhost:8288
In production, the architecture is closer to:
Your Next.js deployment
│
│ /api/inngest
│
▼
Inngest Cloud
│
│
▼
Function execution
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
So after deploying the application, Inngest needs to be able to reach that endpoint.
For production, configure:
INNGEST_EVENT_KEY=...
INNGEST_SIGNING_KEY=...
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
If you're working alone, you can start with:
Local → Production
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
and look at the run.
You should be able to see:
Function
↓
Run
↓
Step 1
↓
Step 2
↓
Step 3
If a step fails, inspect that step.
For example:
user-created
✓ get-user
✓ send-welcome-email
✗ sync-crm
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
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
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 () => {
// ...
});
works.
But this:
await step.run("create-crm-contact", async () => {
// ...
});
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
Bad:
step-1
step-2
step-3
step-4
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);
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,
});
}
);
The SDK exposes logging interfaces such as info, warn, error, and debug.
Keep logs useful.
Instead of:
console.log("here");
prefer:
logger.info("Starting CRM synchronization", {
userId,
});
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(...);
});
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,
});
});
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();
});
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();
});
Now you get a much clearer workflow:
send-email ✓
sync-crm ✓
generate-report ✓
notify-slack ✗
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);
});
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
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,
},
});
Then a function can listen:
export const processOrder = inngest.createFunction(
{
id: "process-order",
},
{
event: "app/order.created",
},
async ({ event, step }) => {
// process order
}
);
This gives you a useful architecture:
Business event
│
▼
Inngest
│
├── send confirmation email
│
├── update analytics
│
├── sync inventory
│
└── notify fulfillment
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
A nice pattern is:
domain/entity.action
For example:
app/user.created
rather than:
newUser
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
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",
},
}
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
38. Keep business logic separate from Inngest
This is another architectural choice that pays off later.
Instead of putting everything directly inside:
inngest.createFunction(...)
you can keep business logic elsewhere.
For example:
src/
├── inngest/
│ └── functions/
│ └── user-created.ts
│
└── services/
├── users.ts
├── email.ts
└── crm.ts
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)
);
}
);
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/
└── ...
And conceptually:
API / Server Action
│
│ inngest.send()
▼
Event
│
▼
Function
│
├── Step
├── Step
├── Step
└── Step
│
▼
Services
│
├── Database
├── Email
├── CRM
└── APIs
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",
});
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,
};
}
);
src/inngest/functions/index.ts
export { userCreated } from "./user-created";
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],
});
.env.local
INNGEST_DEV=1
Then start two terminals.
Terminal 1:
npm run dev
Terminal 2:
npx inngest-cli@latest dev
Then open:
http://localhost:8288
Trigger:
await inngest.send({
name: "app/user.created",
data: {
userId: "user_123",
},
});
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,
},
});
2. Function
Something should react to that event.
inngest.createFunction(...)
3. Step
This is a meaningful unit of work.
await step.run("send-email", async () => {
// ...
});
4. Serve
This exposes your functions to Inngest.
serve({
client: inngest,
functions: [...],
});
5. Dev Server / Dashboard
This is where you inspect what actually happened.
localhost:8288
That's most of the foundation.
42. Common mistakes
Mistake #1: Forgetting the /api/inngest route
You created your function:
inngest.createFunction(...)
but forgot:
app/api/inngest/route.ts
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
Otherwise the SDK defaults to cloud mode unless configured otherwise.
Mistake #3: Hard-coding secrets
Don't commit:
signingKey: "..."
Use:
INNGEST_SIGNING_KEY=...
instead.
Mistake #4: Putting side effects outside steps
Instead of:
await chargeCustomer();
await step.run("save-order", ...);
prefer:
await step.run("charge-customer", async () => {
await chargeCustomer();
});
await step.run("save-order", async () => {
await saveOrder();
});
Mistake #5: Making one enormous step
Avoid:
await step.run("do-everything", async () => {
// 500 lines
});
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"
to:
id: "user-created-v2"
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
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
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
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:
- [ ]
inngestis installed - [ ]
src/inngest/client.tsexists - [ ] Functions live under
src/inngest/functions/ - [ ] Every function has a stable ID
- [ ] Functions have event triggers
- [ ]
/api/inngestexists - [ ]
serve()includes all your functions - [ ]
INNGEST_DEV=1is 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,
},
});
A function reacts:
inngest.createFunction(...)
And the actual work is broken into durable steps:
await step.run("send-email", async () => {
// ...
});
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
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)