Skip to main content

Your First Inngest Function: From Zero to Running

In Article 3 we mapped out exactly what Inngest is and why it exists. Now we build.

By the end of this article you will have:

  • Inngest installed in a real project
  • The Dev Server running locally at http://localhost:8288
  • A working function that receives an event and executes two steps
  • A trigger — both from the Dev Server UI and from your app's code
  • A mental map of every file you created and why it exists

We'll go slowly. Every terminal command gets explained. Every config option gets defined. Every file gets a "why does this exist?" before we write it. This is the foundation that every later article builds on — getting it right here matters.

Let's go.


Quick Reference

What we're building: A user signup workflow — fires when a user/account.created event arrives, runs two steps (create a profile record, send a welcome email), and completes.

Prerequisites before starting:

  • Node.js v18 or later installed (download here)
  • An existing Next.js or Express project (or create one — instructions below)
  • A terminal you're comfortable using

Time to complete: ~20 minutes


What You Need to Know First

Required reading (in order):

  1. Event-Driven Architecture: Why Your App Needs It
  2. Events, Queues, and Workers: The Building Blocks
  3. Inngest: What It Is and How It Fits In

You should be comfortable with:

  • Running commands in a terminal
  • TypeScript basics: import/export, async/await, arrow functions
  • The concept of an HTTP route/endpoint

What We'll Cover in This Article

By the end of this guide, you'll have:

  • Installed the Inngest SDK
  • Run the Dev Server locally
  • Created an Inngest client
  • Registered a /api/inngest endpoint
  • Written a two-step function triggered by an event
  • Fired the function from the Dev Server UI
  • Fired the function from a real API route in your app
  • Read the execution trace in the Dev Server dashboard

What We'll Explain Along the Way

  • What the Inngest client is and why you need it
  • What serve() does and why it must handle GET, POST, and PUT
  • What INNGEST_DEV=1 means and when to use it
  • How the Dev Server discovers your functions automatically
  • What happens inside the Dev Server when a function runs

Part 1: Setup

Step 1 — Check your Node.js version

Inngest requires Node.js v18 or later. Let's verify what you have:

node --version
# Should output: v18.x.x, v20.x.x, or v22.x.x

If you see v16 or earlier, download the LTS version of Node.js before continuing. Node.js v18 introduced the global fetch API which Inngest's SDK relies on.

Step 2 — Get a project to work in

You need an existing web project. Inngest isn't an app you run standalone — it lives inside your application's codebase. If you don't have a project yet, create one now using whichever framework you prefer.

Option A — Next.js (App Router)

npx create-next-app@latest inngest-guide \
--typescript \
--eslint \
--tailwind \
--app \
--src-dir \
--import-alias="@/*"

cd inngest-guide

Option B — Express with TypeScript

mkdir inngest-guide && cd inngest-guide
npm init -y
npm install express
npm install --save-dev typescript tsx @types/express @types/node

Then create a minimal tsconfig.json:

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
}

And a minimal src/index.ts to get started:

// src/index.ts
import express from "express";

const app = express();
app.use(express.json()); // Important: Inngest sends JSON payloads

app.get("/", (req, res) => {
res.json({ message: "Hello from Express + Inngest" });
});

app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});

Part 2: Installing and Running Inngest

Step 3 — Install the Inngest SDK

In your project root, run:

npm install inngest

That's the only package you need. The SDK includes everything: the client, the serve() handler, all step primitives, and the binaries needed to run the Dev Server.

You should see inngest appear in your package.json dependencies:

{
"dependencies": {
"inngest": "^4.x.x"
}
}

Note on version: This guide is written for Inngest SDK v4. The TypeScript SDK follows semantic versioning — ^4.x.x means you'll get patch and minor updates but not a breaking major version change. If you're reading this after a major version bump, check the Inngest migration guide for what changed.

Step 4 — Start your app's development server

Before starting the Inngest Dev Server, your app needs to be running. The Dev Server needs to be able to call your app's /api/inngest endpoint.

For Next.js:

# The INNGEST_DEV=1 flag tells the SDK to connect to your local Dev Server
# instead of Inngest Cloud. Add this to your .env.local to avoid
# typing it every time.
INNGEST_DEV=1 npm run dev

You'll see Next.js start on http://localhost:3000.

For Express:

INNGEST_DEV=1 npx tsx watch src/index.ts

tsx watches your TypeScript files and restarts the server when they change — essential for development.

You might be wondering: What does INNGEST_DEV=1 actually do?

Good question. When your code calls inngest.send(...) to fire an event, the SDK needs to know where to send it. In production, that's Inngest's cloud platform. Locally, that's the Dev Server running on your machine at http://localhost:8288. Setting INNGEST_DEV=1 tells the SDK "we're in local dev mode — route events to localhost:8288, not the cloud."

Add it to your environment file so you never forget:

Next.js — add to .env.local:

# .env.local
INNGEST_DEV=1

Express — add to .env:

# .env
INNGEST_DEV=1

Step 5 — Start the Inngest Dev Server

Open a second terminal (keep your app running in the first). Run:

# For Next.js (runs on port 3000 by default):
npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:3000/api/inngest

# For Express (runs on port 3000 by default):
npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:3000/api/inngest

The --ignore-scripts=false flag is required because the Inngest npm package uses lifecycle scripts to install the CLI binary. Without this flag, npx might skip those scripts and the binary won't be found.

The -u flag tells the Dev Server where your app's Inngest endpoint lives. We haven't created that endpoint yet — but the Dev Server will keep retrying until it finds it.

You should see output like this:

12:33PM INF devserver > service starting
12:33PM INF devserver > autodiscovering locally hosted SDKs
12:33PM INF api > starting server addr=0.0.0.0:8288

Inngest dev server online at 0.0.0.0:8288, visible at the following URLs:

- http://127.0.0.1:8288 (http://localhost:8288)

Open http://localhost:8288 in your browser. You'll see the Dev Server dashboard — currently empty, because we haven't written any functions yet. Keep this tab open. You'll be watching it closely as we work through the rest of this article.


Part 3: The Three Files You Need

Inngest requires three things in your codebase:

  1. A client — the object you use to create functions and send events
  2. Your functions — the actual background work
  3. A serve endpoint — the HTTP route that Inngest calls to execute your functions

Let's create them one at a time.

File 1 — The Inngest Client

The client is the central object that connects your code to Inngest. You create it once and import it everywhere.

Create this file:

// src/inngest/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
// A unique identifier for your app within Inngest.
// This shows up in the Dev Server and Inngest Cloud dashboards.
// Use something descriptive — your app's name is a good choice.
id: "my-app",
});

That's it. The id field is the only required option. It identifies your app in the Dev Server and Inngest Cloud, so pick something you'll recognise.

You might wonder: why do you need a separate client rather than just calling Inngest's SDK functions directly? Two reasons:

  1. Configuration lives in one place. If you ever need to add an event signing key, a base URL, or middleware, you do it once in the client. Every function and every inngest.send() call in your entire codebase inherits those settings.

  2. Type safety. The client is also where you'll eventually define your event schemas, giving you full TypeScript autocomplete when sending and receiving events. We'll cover that in a later article.

File 2 — Your First Function

Now let's write the actual background work. We'll build a user signup workflow with two steps: creating a profile record and sending a welcome email.

Create this file:

// src/inngest/functions.ts
import { inngest } from "./client";

// In a real app, these would be your actual services.
// We're using simple stubs here so you can focus on the Inngest concepts.
const db = {
profiles: {
create: async (data: { userId: string; email: string; name: string }) => {
// Simulates a ~200ms database write
await new Promise((resolve) => setTimeout(resolve, 200));
console.log(` [DB] Profile created for ${data.email}`);
return { profileId: `profile_${data.userId}` };
},
},
};

const emailService = {
sendWelcome: async (data: { to: string; name: string }) => {
// Simulates a ~300ms email API call
await new Promise((resolve) => setTimeout(resolve, 300));
console.log(` [Email] Welcome email sent to ${data.to}`);
return { messageId: `msg_${Date.now()}` };
},
};

// ─────────────────────────────────────────────────────────────────
// The function definition
// ─────────────────────────────────────────────────────────────────
export const handleUserSignup = inngest.createFunction(
// ① Configuration object — who is this function and how should it behave?
{
id: "handle-user-signup", // Unique ID for this function. Must be stable across deploys.
retries: 3, // Retry up to 3 times on failure (default is also 3)
},

// ② Trigger — what event causes this function to run?
{ event: "user/account.created" },

// ③ Handler — the actual work, receives event data and the step object
async ({ event, step }) => {
// event.data contains whatever you passed in inngest.send({ data: ... })
// step is the object you use to define durable, checkpointed units of work

// ── Step 1: Create the user's profile ──────────────────────────────────
// step.run(id, fn) wraps a unit of work.
// - id: a stable, unique name for this step within the function
// - fn: the async function to run
//
// If this step succeeds, its return value is saved by Inngest.
// If a later step fails and the function retries, this step is
// SKIPPED and its saved result is replayed — not re-executed.
const profile = await step.run("create-user-profile", async () => {
return await db.profiles.create({
userId: event.data.userId,
email: event.data.email,
name: event.data.name,
});
});

// ── Step 2: Send the welcome email ────────────────────────────────────
// Notice: we're using `profile.profileId` from step 1.
// This is how steps pass data to each other — the return value of
// step.run() is the saved result, available to everything that follows.
const emailResult = await step.run("send-welcome-email", async () => {
return await emailService.sendWelcome({
to: event.data.email,
name: event.data.name,
});
});

// The function's return value is stored as the run result.
// You can see it in the Dev Server dashboard.
return {
profileId: profile.profileId,
emailMessageId: emailResult.messageId,
status: "signup complete",
};
},
);

Take a moment to study this. There are four parts:

The config object sets the function's ID and retry behaviour. The id must be unique within your app and must stay stable — if you rename it, Inngest treats it as a new function, which can break in-progress runs.

The trigger { event: "user/account.created" } means this function runs every time an event with that exact name arrives at Inngest. The naming convention uses / as a domain separator: user/account.created belongs to the user domain, describing the account.created action.

The handler receives event (the full event payload, including event.data) and step (the object for creating checkpointed work). The handler is an async function — you can use await freely inside it.

The steps are where your actual work happens. Each step.run() call takes a unique id and an async callback. When the step succeeds, Inngest saves its return value. If this function retries later, completed steps are replayed from their saved results, not re-executed.

File 3 — The Serve Endpoint

The serve endpoint is the HTTP route that Inngest calls to discover and execute your functions. Think of it as your app's Inngest interface — a single door through which all communication with Inngest passes.

For Next.js (App Router):

// src/app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { handleUserSignup } from "@/inngest/functions";

// serve() creates the route handler for the Inngest endpoint.
// It needs:
// - client: your Inngest client instance
// - functions: an array of ALL functions you want Inngest to know about
//
// Every function you write must be registered here, or Inngest won't know it exists.
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [
handleUserSignup,
// add more functions here as you create them
],
});

For Express:

// src/index.ts  (your main Express file)
import express from "express";
import { serve } from "inngest/express";
import { inngest } from "./inngest/client";
import { handleUserSignup } from "./inngest/functions";

const app = express();
app.use(express.json()); // Must come BEFORE the Inngest route

// serve() returns middleware-compatible handlers for Express.
// The path /api/inngest is the convention — you can change it,
// but you'd need to update the -u flag in your Dev Server command too.
app.use(
"/api/inngest",
serve({
client: inngest,
functions: [handleUserSignup],
}),
);

app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});

You might wonder: why does the serve handler export GET, POST, and PUT? Here's why each matters:

  • GET — The Dev Server calls this to discover what functions your app has registered. When you open the Dev Server dashboard and see your function listed, it came via a GET request to this endpoint.
  • POST — Inngest calls this to execute a function (or a step within a function). This is the main execution path.
  • PUT — Used for syncing your app's function list with Inngest Cloud during deployment.

If you forget to export one of these, some part of the Inngest flow will silently break. Export all three.


Part 4: Your File Structure

After creating these three files, your project should look like this:

Next.js:

src/
├── app/
│ └── api/
│ └── inngest/
│ └── route.ts ← The serve endpoint
└── inngest/
├── client.ts ← The Inngest client
└── functions.ts ← Your functions

Express:

src/
├── index.ts ← Express app + serve endpoint
└── inngest/
├── client.ts ← The Inngest client
└── functions.ts ← Your functions

As your project grows, you'll add more function files to src/inngest/ — one file per function, or grouped by domain. The client stays in one place. The serve endpoint just needs its functions array updated when you add new ones.


Part 5: Watching It Run

Everything is in place. Let's fire the function and watch it execute.

Make sure both terminals are still running:

  • Terminal 1: your app (npm run dev or tsx watch)
  • Terminal 2: the Dev Server (npx inngest-cli@latest dev)

Check the Dev Server at http://localhost:8288. Click Apps in the left navigation. You should see your app listed with the handle-user-signup function discovered. If you see "No apps found," wait a moment — the Dev Server auto-discovers apps by scanning common ports. If it still doesn't appear, check that your app is running and that the /api/inngest endpoint exists.

Method 1 — Trigger from the Dev Server UI

This is the fastest way to test a function without writing any app code first.

  1. In the Dev Server at http://localhost:8288, click Functions in the left navigation
  2. Find handle-user-signup in the list and click it
  3. Click the Invoke button (top right)
  4. You'll see a JSON editor for the event payload. Replace its contents with:
{
"data": {
"userId": "usr_01J8G447",
"email": "alex@example.com",
"name": "Alex Chen"
}
}
  1. Click Invoke Function

Switch to the Runs tab. You should see a new run appear with status "Running" — and within a second or two, it should flip to "Completed."

Click on the run to open its detail view. You'll see:

  • The triggering event, with its full payload
  • A timeline showing each step that ran
  • The duration of each step
  • The return value of each step
  • The final return value of the function

This is the observability that makes Inngest so valuable for debugging. Every execution is fully traced — you don't need to dig through server logs.

Method 2 — Trigger from your app's code

In a real app, events are fired from your existing API routes when something meaningful happens. Let's create a trigger route that simulates a user completing signup.

For Next.js — create a new route:

// src/app/api/signup/route.ts
import { NextResponse } from "next/server";
import { inngest } from "@/inngest/client";

export async function POST(request: Request) {
// In a real app, this is where you'd:
// 1. Validate the request body
// 2. Hash the password
// 3. Insert the user row into your database
// 4. Create a session / JWT
//
// Here we'll skip all that and just fire the event.

const body = await request.json();

// inngest.send() fires the event.
// This is a fast, non-blocking call — it hands the event to Inngest
// and returns immediately. Your API route doesn't wait for the
// background function to complete.
await inngest.send({
name: "user/account.created", // Must match the trigger in your function
data: {
userId: `usr_${Date.now()}`,
email: body.email,
name: body.name,
},
});

// This response goes back to the user immediately —
// before the background function has even started.
return NextResponse.json({
message: "Account created, welcome email on its way!",
});
}

For Express — add a route to src/index.ts:

// Add this to your Express app in src/index.ts
app.post("/api/signup", async (req, res) => {
await inngest.send({
name: "user/account.created",
data: {
userId: `usr_${Date.now()}`,
email: req.body.email,
name: req.body.name,
},
});

res.json({ message: "Account created, welcome email on its way!" });
});

Now trigger it with curl:

curl -X POST http://localhost:3000/api/signup \
-H "Content-Type: application/json" \
-d '{"email": "jordan@example.com", "name": "Jordan"}'

You should see this response immediately:

{ "message": "Account created, welcome email on its way!" }

And then, within a second, a new run appears in the Dev Server dashboard. The API route responded before the background function even started — exactly as intended.

Watch the run detail. You'll see:

  • Step 1 (create-user-profile): ran for ~200ms, returned a profileId
  • Step 2 (send-welcome-email): ran for ~300ms, returned a messageId
  • Function result: the object we returned at the end of the handler

Part 6: What Just Happened (The Full Picture)

Let's trace through what happened when you called /api/signup, because understanding this sequence is the key to everything that follows.

① Your terminal runs: curl -X POST /api/signup

② Express/Next.js handles the POST request

③ Your route handler runs:
- Calls inngest.send({ name: "user/account.created", data: {...} })
- This sends the event over HTTPS to the Dev Server (localhost:8288)
- inngest.send() returns as soon as the event is accepted
- Your route returns 200 with the JSON response
- ← The curl command sees the response here, immediately

④ Dev Server receives the event
- Stores it durably
- Matches it to the "handle-user-signup" function (trigger: "user/account.created")
- Schedules the function to run

⑤ Dev Server calls POST /api/inngest on your app
- Sends the event payload + an empty step state (first execution)
- Your serve handler receives this and runs the function handler

⑥ Function handler starts
- Reaches step.run("create-user-profile", ...)
- The SDK detects this is the first step
- Runs the callback: creates the profile (~200ms)
- Returns the result to the Dev Server via the HTTP response
- The function handler STOPS here (doesn't continue yet)

⑦ Dev Server receives the step result
- Saves it to its state store
- Schedules the next step

⑧ Dev Server calls POST /api/inngest again
- This time it sends: event payload + saved state from step 1
- Your serve handler runs the function handler again

⑨ Function handler runs again
- Reaches step.run("create-user-profile", ...)
- The SDK finds this step ID in the saved state
- Returns the saved result immediately (does NOT re-run the callback)
- Continues to step.run("send-welcome-email", ...)
- This is a new step — runs the callback: sends the email (~300ms)
- Returns the result to the Dev Server

⑩ Dev Server receives the final result
- No more steps to run
- Marks the function run as "Completed"
- Stores the final return value

Notice how the function handler is called twice — once per step. This is the incremental execution model from Article 3. Each call is a short-lived HTTP request. This is why Inngest works on serverless: no single invocation runs for more than the duration of one step.


Part 7: What to Do When Things Go Wrong

Here are the most common problems you'll hit setting up for the first time.

Problem: Dev Server shows "No apps found" or your function doesn't appear

Diagnosis steps:

# 1. Verify your app is actually running
curl http://localhost:3000

# 2. Verify the Inngest endpoint exists and responds
curl http://localhost:3000/api/inngest
# Should return something like: {"message":"Inngest endpoint active"}
# NOT a 404 or connection refused error

# 3. Verify the endpoint is discoverable
curl -X GET http://localhost:3000/api/inngest
# Should return JSON listing your registered functions

Common causes:

  • Your app isn't running — start it first before the Dev Server
  • The serve endpoint path doesn't match the -u flag in your Dev Server command
  • You forgot to export GET from the route handler (Next.js App Router)
  • You registered functions in serve({ functions: [] }) but the array is empty

Fix: Make sure the function is exported from functions.ts and added to the functions array in the serve handler.

Problem: Function appears in Dev Server but runs fail immediately

What you see: The run appears in the Runs tab but shows status "Failed" with no steps completing.

Common causes:

// ❌ Missing await — the function returns before the step finishes
const result = step.run("my-step", async () => {
// forgot await!
return await doSomething();
});

// ✅ Correct
const result = await step.run("my-step", async () => {
return await doSomething();
});

Also check that express.json() middleware is registered before the Inngest serve handler in Express. Inngest sends JSON bodies — without the middleware, req.body is undefined and the handler crashes.

// ❌ Wrong order — middleware after the Inngest handler
app.use("/api/inngest", serve({ ... }));
app.use(express.json()); // Too late

// ✅ Correct order — middleware first
app.use(express.json());
app.use("/api/inngest", serve({ ... }));

Problem: inngest.send() throws a network error

What you see:

Error: fetch failed — connect ECONNREFUSED 127.0.0.1:8288

Cause: INNGEST_DEV=1 is set but the Dev Server isn't running, so the SDK tries to send the event to localhost:8288 and there's nothing there.

Fix: Start the Dev Server in a separate terminal. Or temporarily remove INNGEST_DEV=1 to send events to Inngest Cloud (you'll need an account and API key for that — covered in Article 12: Deploying Inngest to Production (coming soon)).

Problem: The function runs twice (you see two runs for one event)

Cause: You likely have two inngest.send() calls triggering the same event, or you're running both the Dev Server auto-discovery and the manual -u flag at the same time.

Fix: Check your code for duplicate sends. If auto-discovery is picking up a second app, use --no-discovery in your Dev Server command to disable it:

npx inngest-cli@latest dev --no-discovery -u http://localhost:3000/api/inngest

Common Misconceptions

❌ Misconception: The function runs inside inngest.send()

Reality: inngest.send() just fires the event. The function runs asynchronously, later, when Inngest calls your /api/inngest endpoint. By the time inngest.send() returns, the function hasn't started yet.

await inngest.send({ name: "user/account.created", data: { ... } });
// At this point: the event has been sent. The function has NOT run yet.
// It will run when Inngest calls your endpoint, a few milliseconds later.
console.log("Event sent — function will run in the background");

❌ Misconception: You need an Inngest account to develop locally

Reality: The Dev Server runs entirely on your machine. No account, no API key, no internet connection required for local development. You only need an Inngest Cloud account when you deploy to production, covered in Article 12: Deploying Inngest to Production (coming soon).

❌ Misconception: Each step.run() is a separate network request to a separate service

Reality: The step runs inside your own app — it's your own code, calling your own database, your own email service. The only network request involved is Inngest calling your /api/inngest endpoint, which is a call from the Dev Server (or Inngest Cloud) to your app. The step callback runs locally in your process.

❌ Misconception: The function id can be anything as long as it's unique

Reality: It can be anything you want, but it must be stable across deployments. Inngest uses the function ID to associate in-progress runs with the correct function definition. If you rename the ID in a deploy while runs are in progress, those runs will become orphaned — they'll keep retrying but nothing will pick them up.

Choose an ID that describes what the function does, not one that includes timestamps or generated values.


Check Your Understanding

Quick Quiz

1. Why does inngest.send() return immediately instead of waiting for the function to finish?

Show Answer

Because the function runs asynchronously in the background. inngest.send() hands the event to Inngest's queue (the Dev Server locally, Inngest Cloud in production) and returns as soon as the event is accepted. The actual function execution happens separately when Inngest calls your /api/inngest endpoint. This is the whole point — your API responds quickly, and the slow work happens in the background.

2. You have a function with three step.run() calls. The second step crashes. When the function retries, which steps actually execute their callback code?

Show Answer

Only step 3 (after you fix the bug). Step 1's callback is skipped — its saved result is replayed from Inngest's state store. Step 2's callback is skipped too — it failed, but once it succeeds on retry, its result is also saved. Step 3 runs its callback for the first time. This is durable execution: retries resume from the point of failure, not from the beginning.

3. What breaks if you forget to add express.json() middleware before the Inngest serve handler?

Show Answer

The req.body object will be undefined when Inngest calls your /api/inngest endpoint with the event payload (a JSON POST request). The serve handler can't parse the incoming data, causing every function invocation to fail. Always register express.json() before the Inngest middleware.

Hands-On Challenge

Extend the handleUserSignup function to add a third step: checking whether the user's email domain is from a business email address, and logging whether it's a business or personal signup.

Starter template:

// Add this as step 3 in your handleUserSignup function
const emailCheck = await step.run("check-email-domain", async () => {
const domain = event.data.email.split("@")[1];
const personalDomains = [
"gmail.com",
"yahoo.com",
"hotmail.com",
"outlook.com",
];
const isBusiness = !personalDomains.includes(domain);

return {
domain,
isBusiness,
type: isBusiness ? "business" : "personal",
};
});

Questions to answer after adding it:

  • Does the existing test run in the Dev Server still work unchanged?
  • What does the run timeline look like with three steps?
  • Try invoking it again with a business email (e.g. alex@acme.com) — what does emailCheck contain?

Summary: Key Takeaways

  • Three files, one pattern. Every Inngest integration needs a client (client.ts), functions (functions.ts), and a serve endpoint (/api/inngest). That's it.
  • inngest.send() is fire-and-forget. It hands the event to Inngest and returns immediately. Your API route doesn't wait for the background function.
  • step.run(id, fn) makes work durable. Each step's result is saved. On retry, completed steps are replayed — not re-executed. This prevents duplicate side effects.
  • The Dev Server is your local Inngest. It runs entirely on your machine. No account needed. It gives you the same observability (event log, run traces, step timelines) that you'd get in production.
  • INNGEST_DEV=1 routes local events to the Dev Server. Without it, inngest.send() tries to reach Inngest Cloud and will fail if you don't have an API key configured.
  • The serve handler must export GET, POST, and PUT. Each serves a different role in how Inngest communicates with your app.
  • Function IDs must be stable. Don't include dynamic values in them. Renaming an ID mid-flight orphans in-progress runs.

What's Next?

You've now got a working Inngest function running locally, visible in the Dev Server, triggered both from the UI and from real application code. The foundation is solid.

In Article 5: Steps — Breaking Work into Durable Units, we go much deeper into step.run() and the full step API. You'll learn how steps share data, how they retry independently, how to run steps in parallel with Promise.all(), and exactly what memoization means in the context of Inngest's execution model.


Version Information

Tested with:

  • inngest: ^4.1.x
  • Node.js: v18.x, v20.x, v22.x
  • TypeScript: 5.x
  • Next.js: 14.x, 15.x (App Router)
  • Express: 4.x

Known differences from SDK v3:

  • In v4, set INNGEST_DEV=1 to connect to the local Dev Server. In v3, this was handled differently. See the v3 → v4 migration guide.

Further reading: