
Inngest Nextjs Patterns
- 79 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
inngest-nextjs-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- inngest-nextjs-patterns
- AI & Agent Building
- AI-coding skill
Inngest Nextjs Patterns by the numbers
- 79 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,292 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill inngest-nextjs-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 79 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with inngest-nextjs-patterns.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when inngest-nextjs-patterns is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted c
What you get
Structured output aligned to inngest-nextjs-patterns: inngest-nextjs-patterns; AI & Agent Building; AI-coding skill.
Files
Inngest + Next.js Patterns
Templates and conventions for building event-driven, durable background work on Inngest from a Next.js App Router project. The skill enforces the conventions Inngest's own docs use (decentralized eventType() schemas, kebab-case stable function IDs, idempotent steps) so that retries, replays, and concurrency controls actually do what they advertise.
When to Apply
Read this skill when you are:
- Setting up Inngest in a Next.js app for the first time (client + route handler + first function)
- Adding a new background job, webhook handler, or scheduled task to a Next.js app
- Defining a new domain event that other functions will trigger on
- Orchestrating multi-step work that must survive process restarts (durable workflows, sagas)
- Reviewing existing Inngest code for event naming, step idempotency, or stable function IDs
- Migrating from
setTimeout/ a queue library / a cron service to Inngest's durable execution model
How to Use
1. First time in this codebase? Read `references/conventions.md` before writing anything. The conventions explain why event names take a specific shape, why function IDs must be stable, and why every step.run must be idempotent — once you understand the reasoning, the templates make sense. 2. Pick a template from the catalog below based on what you're creating. 3. Render it by substituting the documented parameters. Each template has a parameter table at the top of its file. 4. Wire it up: new functions must be added to src/inngest/functions/index.ts (the registry the route handler reads). The conventions doc shows the registry pattern.
Available Templates
| Template | Output Path | Use When |
|---|---|---|
| `client.ts.template` | src/inngest/client.ts | One-time setup: create the singleton Inngest client. Generate once per app. |
| `route-handler.ts.template` | src/app/api/inngest/route.ts | One-time setup: the App Router endpoint Inngest hits to invoke your functions. Generate once per app. |
| `event.ts.template` | src/inngest/events/{domain}.ts | Defining a new typed event (with Zod schema) that producers send and functions consume. |
| `function.ts.template` | src/inngest/functions/{name}.ts | Standard event-triggered durable function with steps. Parameterized retries, concurrency, throttle, debounce, cancelOn, onFailure. |
| `function-cron.ts.template` | src/inngest/functions/{name}.ts | Scheduled function — runs on a cron expression (with timezone). Optionally also accepts a manual event trigger for ad-hoc invocation. |
| `function-fan-out.ts.template` | src/inngest/functions/{name}.ts | Orchestrator that receives one event and fans out work — via step.sendEvent (fire-and-forget) or Promise.all(step.invoke(...)) (wait for results). |
Quick Reference
- Event name shape:
domain/entity.verb_past— e.g.,app/user.created,shop/order.placed,billing/invoice.paid. The conventions doc has the full rule. - Function `id`: stable kebab-case, never rename it after deploy — Inngest uses it as the durable state key.
- Step `id`: descriptive kebab-case, also durable; changing one re-runs that step on replay.
- Idempotency: every
step.runmust be safely retryable. If it calls a third-party API that mutates state, pass an idempotency key. - Dev server:
npx inngest-cli@latest dev, then UI at http://localhost:8288. SetINNGEST_DEV=1in.env.localto point the SDK at it.
Project Layout Assumption
Templates assume a src/ directory with App Router:
src/
├── app/api/inngest/route.ts # generated by route-handler template
└── inngest/
├── client.ts # generated by client template
├── events/ # one file per domain (events.shop.ts, events.user.ts)
│ └── {domain}.ts # generated by event template
└── functions/
├── index.ts # registry: re-exports all functions as an array
└── {name}.ts # generated by function/cron/fan-out templatesIf your project uses a different layout (no src/, or Pages Router), override the output paths in config.json. The conventions doc explains the trade-offs.
Setup
config.json controls path overrides. On first use, check whether its fields are filled in — if not, ask the user before generating (project root differs from the default assumption).
Related Skills
vercel:ai-sdk— if your Inngest functions invoke LLMs, the AI SDK gives you streaming + provider abstraction; pair them withstep.runfor retry safety.vercel:vercel-functions— Inngest'sserve()adapter for Next.js runs as a Vercel Serverless Function by default; the AI SDK skill has notes on Fluid Compute and timeout extension for long agent runs.
// =============================================================================
// Template: src/inngest/client.ts
// Parameters:
// - appId (required): stable identifier for this Inngest app, kebab-case.
// Example: "acme-storefront". Never change after deploy —
// Inngest associates run history and replays with this ID.
//
// Generate ONCE per Next.js app. Subsequent functions and event-senders import
// from here. The client is a singleton — do not instantiate Inngest elsewhere.
//
// Env vars (read automatically by the SDK):
// INNGEST_DEV=1 → connect to local dev server at http://localhost:8288
// INNGEST_EVENT_KEY=... → required in production for sending events
// INNGEST_SIGNING_KEY=...→ required in production for the route handler to
// verify requests from Inngest Cloud
//
// See references/conventions.md § Function ID Stability for why appId is permanent.
// =============================================================================
import { Inngest } from "inngest";
export const inngest = new Inngest({
id: "<APP_ID>", // e.g., "acme-storefront"
// Optional: pass `isDev: true` to force dev mode regardless of env vars.
// Leave this off in committed code — use INNGEST_DEV=1 locally instead.
});
// =============================================================================
// Template: src/inngest/events/<DOMAIN>.ts
// Parameters:
// - DOMAIN (required): the event namespace, lowercase, no slash.
// Examples: "user", "order", "billing", "ai".
// - EVENT_NAME (required): full event name in `<domain>/<entity>.<verb_past>`
// form. Examples: "user/account.created",
// "shop/order.placed", "billing/invoice.paid".
// - SYMBOL (required): camelCase symbol exported for use as a trigger.
// Convention: matches the verb_past form, e.g.,
// accountCreated, orderPlaced, invoicePaid.
// - PASCAL_SYMBOL (required): PascalCase form of SYMBOL, used in the sender
// helper name. E.g., SYMBOL=orderPlaced →
// PASCAL_SYMBOL=OrderPlaced → sendOrderPlaced.
// - PAYLOAD shape: the `data` schema. Define with Zod for runtime validation
// at the system boundary (webhooks, public APIs); use
// staticSchema<T>() if the producer is fully internal and
// you trust TypeScript at compile time.
//
// One file per DOMAIN. Co-locating events keeps a domain's vocabulary in one
// place — when you change a payload, you change the schema and the producers
// in the same edit.
//
// See references/conventions.md § Event Naming and § One-Schema-Per-Event.
// =============================================================================
import { eventType } from "inngest";
import { z } from "zod";
import { inngest } from "@/inngest/client";
// -- Define the typed event ---------------------------------------------------
//
// `eventType` binds a string name to a payload schema. Functions that use this
// as a trigger get fully typed `event.data`. Senders use `.create()` to build a
// validated payload — TypeScript catches missing fields at the call site.
export const <SYMBOL> = eventType("<EVENT_NAME>", {
schema: z.object({
// Replace with your actual payload shape. Keep payloads small — pass IDs,
// not whole objects. The function will reload fresh state inside step.run.
id: z.string(),
// userId: z.string(),
// amountCents: z.number().int().nonnegative(),
}),
});
// Optional: if runtime validation is overkill (purely internal producer),
// use staticSchema instead. Trade: no validation, but no Zod dependency.
//
// import { staticSchema } from "inngest";
// type <PASCAL_SYMBOL>Payload = { id: string };
// export const <SYMBOL> = eventType("<EVENT_NAME>", {
// schema: staticSchema<<PASCAL_SYMBOL>Payload>(),
// });
// -- Typed sender helper ------------------------------------------------------
//
// Wrap inngest.send so callers don't have to remember the event name. Using
// `<SYMBOL>.create(...)` validates the payload at the call site.
//
// The function name is `send<PASCAL_SYMBOL>` — e.g., sendOrderPlaced. Substitute
// the PascalCase form into the identifier; the angle brackets disappear.
export async function send<PASCAL_SYMBOL>(data: z.infer<typeof <SYMBOL>.schema>) {
await inngest.send(<SYMBOL>.create(data));
}
// =============================================================================
// Template: src/inngest/functions/<FN_FILE>.ts
// Parameters:
// - FN_ID (required): stable kebab-case function ID. Never rename.
// - FN_NAME (optional): friendly display name for the dashboard.
// - CRON_EXPR (required): 5-field cron expression, optionally prefixed with
// "TZ=<IANA_zone> " for non-UTC schedules. Examples:
// "0 9 * * 1-5" → 9am weekdays UTC
// "*/15 * * * *" → every 15 minutes UTC
// "TZ=America/New_York 0 9 * * 1-5"
// → 9am ET weekdays (DST-aware)
// Default timezone is UTC. The TZ= prefix is part
// of the same string passed to cron().
// - MANUAL_TRIGGER (optional): also accept an event for ad-hoc invocation.
// Useful for testing the cron logic from the dev UI.
//
// Cron-only functions receive NO event payload. The handler argument has step,
// logger, runId — but `event` is unused (it exists, but data is empty).
//
// AFTER GENERATING: add this function to src/inngest/functions/index.ts.
//
// See references/conventions.md § Cron vs Event Triggers.
// =============================================================================
import { cron } from "inngest";
import { inngest } from "@/inngest/client";
// If you also want a manual trigger, import an event symbol:
// import { <MANUAL_TRIGGER_SYMBOL> } from "@/inngest/events/<DOMAIN>";
export const <fnSymbol> = inngest.createFunction(
{
id: "<FN_ID>",
name: "<FN_NAME>",
// Cron-triggered functions run on schedule. Use the TZ= prefix for any
// non-UTC schedule — DST-aware. Without TZ=, the expression evaluates in UTC.
triggers: [
cron("<CRON_EXPR>"), // e.g., cron("TZ=America/New_York 0 9 * * 1-5")
// Optional: allow manual invocation by sending this event from anywhere.
// Up to 10 triggers per function.
// <MANUAL_TRIGGER_SYMBOL>,
],
// Flow control for crons is usually about preventing overlap when a run
// takes longer than the schedule interval. concurrency: { limit: 1 } makes
// the cron strictly serial — a slow run delays the next tick instead of
// stacking up parallel runs.
concurrency: { limit: 1, scope: "fn" },
// Crons usually shouldn't retry forever — a missed tick is often better
// than a stuck queue. Tune based on idempotency of the work.
retries: 2,
},
async ({ step, logger }) => {
logger.info({ at: new Date().toISOString() }, "<FN_ID> tick");
// Typical cron pattern: enumerate work, then fan out via events instead of
// doing all of it inline. Keeps each individual job independently retryable.
const items = await step.run("collect-work", async () => {
// Query the DB / API for items to process this tick.
// Return an array of identifiers — keep step results small.
return [] as Array<{ id: string }>;
});
// Option A: fan out one event per item (preferred when work is per-item).
// The other functions handle retries and concurrency for their own work.
if (items.length > 0) {
await step.sendEvent(
"fan-out",
items.map((item) => ({
name: "<domain/entity.verb_past>", // the per-item event
data: { id: item.id },
})),
);
}
// Option B: do the work inline if it's a small, atomic batch.
// for (const item of items) {
// await step.run(`process-${item.id}`, async () => doWork(item));
// }
return { processed: items.length };
},
);
// =============================================================================
// Template: src/inngest/functions/<FN_FILE>.ts
// Parameters:
// - FN_ID (required): stable kebab-case ID. Example: "import-contacts".
// - PARENT_EVENT (required): the trigger event symbol. The orchestrator's job.
// - CHILD_EVENT (required): the per-item event symbol that worker functions consume.
// - MODE (required): one of:
// "fire-and-forget" → step.sendEvent for each item, return immediately.
// Use when the orchestrator doesn't need results.
// "wait-for-results" → Promise.all(step.invoke(...)) per item.
// Orchestrator returns once all children finish.
//
// Fan-out has two distinct shapes — pick deliberately. Fire-and-forget scales
// to millions of items (events are cheap). wait-for-results is bounded by the
// concurrency of the child function and the orchestrator's runtime.
//
// Why fan out at all? Each child becomes its own durable run — it retries,
// reports, and observes independently in the dashboard. One bad item can't
// stall the others.
//
// See references/conventions.md § Fan-Out Patterns.
// =============================================================================
import { <PARENT_EVENT> } from "@/inngest/events/<DOMAIN>";
// For "wait-for-results" mode, also import the child function:
// import { <childFnSymbol> } from "@/inngest/functions/<child-fn-file>";
import { inngest } from "@/inngest/client";
export const <fnSymbol> = inngest.createFunction(
{
id: "<FN_ID>",
name: "<FN_NAME>",
// Orchestrators usually need higher concurrency than their children — the
// bottleneck is the child function's limit, not this one.
concurrency: { limit: 50, scope: "fn" },
retries: 3,
// v4 trigger array — pass the imported event symbol directly.
triggers: [<PARENT_EVENT>],
},
async ({ event, step, logger }) => {
// 1. Resolve the work set INSIDE a step so the list is durable. Re-running
// the orchestrator must produce the same fan-out, or replays will
// double-dispatch.
const items = await step.run("resolve-work-set", async () => {
// Return an array of stable identifiers, not whole objects.
return [{ id: "1" }, { id: "2" }] as Array<{ id: string }>;
});
logger.info({ count: items.length }, "<FN_ID> fan-out");
// -------------------------------------------------------------------------
// MODE: fire-and-forget
// -------------------------------------------------------------------------
// Send one event per item. Each child run is independent. Return now.
await step.sendEvent(
"dispatch",
items.map((item) => ({
name: "<child/event.name>", // e.g., "import/contact.requested"
data: { id: item.id, parentRunId: event.id },
})),
);
return { dispatched: items.length };
// -------------------------------------------------------------------------
// MODE: wait-for-results
// -------------------------------------------------------------------------
// Swap the return above for the block below. Each step.invoke runs the
// child function inline (within the same logical workflow) and returns
// its result. Promise.all parallelizes the calls.
//
// Bound the batch size — Promise.all over 10,000 invokes will exhaust
// memory. Chunk if items.length > a few hundred.
//
// const results = await Promise.all(
// items.map((item, idx) =>
// step.invoke(`child-${item.id}-${idx}`, {
// function: <childFnSymbol>,
// data: { id: item.id },
// timeout: "10m",
// }),
// ),
// );
// return { processed: results.length, results };
},
);
// =============================================================================
// Template: src/inngest/functions/<FN_FILE>.ts
// Parameters:
// - FN_ID (required): stable kebab-case function ID. Never rename.
// Example: "process-order", "send-welcome-email".
// - FN_NAME (optional): friendly display name for the Inngest dashboard.
// - TRIGGER_SYMBOL (required): imported event symbol from
// src/inngest/events/<domain>.ts
// - retries (optional, default 4): integer 0–20. 0 disables retries.
// - concurrency (optional): number (limit) OR full object
// { limit, key, scope: "fn" | "env" | "account" }
// - throttle / rateLimit / debounce (optional): see flow-control blocks below
// - cancelOn (optional): array of { event, match } for cancellation
// - onFailure (optional, boolean): include the after-retries-exhausted hook
//
// AFTER GENERATING: add this function to src/inngest/functions/index.ts so the
// route handler picks it up. The registry pattern is in references/conventions.md.
//
// See references/conventions.md § Step Idempotency before filling in step bodies.
// =============================================================================
import { <TRIGGER_SYMBOL> } from "@/inngest/events/<DOMAIN>";
import { inngest } from "@/inngest/client";
export const <fnSymbol> = inngest.createFunction(
{
id: "<FN_ID>",
name: "<FN_NAME>", // optional — delete if same as id
// -- Flow control (all optional) ------------------------------------------
// Pick the controls your function actually needs. Don't add them "just in
// case" — every control has a runtime cost and a debugging cost.
retries: 4, // default 4. Set 0 only if the work is genuinely fire-and-forget.
concurrency: {
// Cap simultaneous runs. Scope "fn" = across all events for THIS function.
// Scope "env" / "account" share the limit across functions.
limit: 10,
scope: "fn",
// Optional: per-key isolation. Different tenants get separate concurrency
// buckets so one heavy tenant can't starve others.
// key: "event.data.accountId",
},
// throttle: { limit: 5, period: "1m", key: "event.data.userId" },
// rateLimit: { limit: 100, period: "1h", key: "event.data.accountId" },
// debounce: { period: "5s", key: "event.data.userId", timeout: "30s" },
// -- Trigger --------------------------------------------------------------
// v4 places triggers inside the config object as an array. Pass the imported
// event symbol directly — it carries the event name AND payload schema, so
// `event.data` below is fully typed.
triggers: [<TRIGGER_SYMBOL>],
// -- Cancellation ---------------------------------------------------------
// Cancel an in-flight run when a matching event arrives. `match` is a JSON
// path on event.data — both events must share that field's value.
// cancelOn: [
// { event: "<domain/entity.verb_past>", match: "data.id" },
// ],
// timeouts: { finish: "30m" }, // cancel run if it exceeds 30 minutes total
// -- After-all-retries-exhausted handler ----------------------------------
// Runs once after the final retry fails. Use for alerting, NOT for retry
// logic — Inngest already did that.
// onFailure: async ({ error, event }) => {
// await alertOpsChannel({
// functionId: "<FN_ID>",
// error: error.message,
// payload: event.data,
// });
// },
},
async ({ event, step, logger, attempt }) => {
logger.info({ attempt, id: event.data.id }, "<FN_ID> start");
// -------------------------------------------------------------------------
// Steps. EVERY external side effect MUST be wrapped in step.run.
//
// The function body re-executes from the top on each step boundary, but
// step.run results are memoized: on replay, Inngest returns the cached
// result instead of running the closure again. That means:
//
// 1. Code OUTSIDE step.run runs multiple times — keep it pure.
// 2. Code INSIDE step.run runs once on success, multiple times on retry.
// It must be idempotent (use idempotency keys, upserts, or check-then-act).
// 3. Step IDs are durable — renaming "charge" → "charge-customer" re-runs
// that step from scratch on replay.
// -------------------------------------------------------------------------
const result = await step.run("<step-id-kebab-case>", async () => {
// Idempotent external side effect goes here.
// Example: stripe.charges.create({ ..., idempotency_key: event.data.id })
return { ok: true, id: event.data.id };
});
// Durable sleep — function actually unloads from memory and resumes later.
// Use this for SLA-style delays ("wait 1h then check"), reminders, etc.
// await step.sleep("wait", "1h");
// await step.sleepUntil("wait-until-renewal", event.data.renewsAt);
// Pause until another event arrives. Returns the matched event or null on
// timeout. Match links events by a shared field (here: data.id).
// const followUp = await step.waitForEvent("await-confirmation", {
// event: "<other/event.name>",
// timeout: "7d",
// match: "data.id",
// });
// if (!followUp) {
// // timeout branch
// }
return { ok: true, result };
},
);
// =============================================================================
// Template: src/app/api/inngest/route.ts
// Parameters: (none — reads from the function registry)
//
// The single endpoint Inngest Cloud (and the local dev server) calls to invoke
// your functions. It exports GET, POST, and PUT — all three are required:
//
// GET → introspection: Inngest fetches the list of registered functions
// POST → invocation: Inngest calls this when an event triggers a function
// PUT → register: Inngest pings this to register new functions on deploy
//
// This file should rarely change. Adding a new function = adding it to
// src/inngest/functions/index.ts (the registry), NOT to this file.
//
// See references/conventions.md § Function Registry.
// =============================================================================
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { functions } from "@/inngest/functions";
export const { GET, POST, PUT } = serve({
client: inngest,
functions,
// Optional: streaming (only on platforms that support it — Vercel doesn't):
// streaming: "allow",
// Optional: override the served path if you mount this at a non-default URL:
// servePath: "/api/inngest",
});
{
"client_path": "src/inngest/client.ts",
"route_path": "src/app/api/inngest/route.ts",
"events_dir": "src/inngest/events",
"functions_dir": "src/inngest/functions",
"schema_library": "zod",
"app_id": "",
"_setup_instructions": {
"client_path": "Where to write the Inngest client singleton. Default assumes Next.js src/ + App Router. Change if your project uses app/ without src/, or Pages Router.",
"route_path": "Where to write the Inngest serve() handler. For App Router, ends in route.ts. For Pages Router, use pages/api/inngest.ts and the templates need light adjustment (default export instead of named GET/POST/PUT exports).",
"events_dir": "Directory for typed event definitions. One file per domain (e.g., events/shop.ts, events/user.ts).",
"functions_dir": "Directory for function definitions plus the registry index.ts.",
"schema_library": "Either 'zod' (runtime validation at boundaries) or 'static' (compile-time only, no Zod dependency). The event template branches on this.",
"app_id": "Stable kebab-case ID for this Inngest app, written into client.ts. NEVER change after deploy — Inngest associates run history with this ID. Example: 'acme-storefront'."
}
}
Gotchas
Failure points discovered while using this skill. Append-only, with dates.
No known gotchas yet — this section will grow as the templates are used in real projects.
<!-- Format for new entries:
Short headline naming the failure mode
1-2 sentence description of what goes wrong and why. Fix: the specific change that resolves it. Added: YYYY-MM-DD -->
{
"version": "1.0.3",
"organization": "personal",
"technology": "Inngest + Next.js",
"discipline": "extraction",
"type": "scaffolding",
"date": "May 2026",
"abstract": "Parameterized templates for building event-driven, durable background work on Inngest from a Next.js App Router project. Covers the one-time setup (client singleton, /api/inngest route handler), recurring scaffolds (typed events with Zod schemas, event-driven and cron-triggered durable functions, fan-out orchestrators), and the conventions that make Inngest's retry/replay/concurrency model actually work (event naming, function-id stability, step idempotency).",
"references": [
"https://www.inngest.com/docs/getting-started/nextjs-quick-start",
"https://www.inngest.com/docs/reference/typescript/v4/functions/triggers",
"https://www.inngest.com/docs/reference/typescript/v4/migrations/v3-to-v4",
"https://www.inngest.com/docs/guides/step-parallelism",
"https://www.inngest.com/docs/guides/throttling",
"https://www.inngest.com/docs/guides/flow-control"
]
}
Inngest + Next.js Conventions
These are the conventions the templates enforce, with the reasoning behind each. Once you understand the reasoning you can make informed exceptions; without it you'll trip the same wires Inngest's own users have tripped.
Event Naming: domain/entity.verb_past
Examples: user/account.created, shop/order.placed, billing/invoice.paid, ai/summary.requested.
The shape is three parts:
1. `domain` — the business area (single token, lowercase). shop, billing, user, ai, comms. 2. `entity` — what the event is about. order, invoice, account. 3. `verb_past` — what just happened. created, placed, paid, requested, cancelled.
Why:
- It's the shape every Inngest example uses. Pattern-matching across the docs is much easier when local events look the same.
- The Inngest dashboard sorts by event name. Grouping by domain prefix lets you scope filters:
shop/*,billing/invoice.*. - Past-tense verbs prevent the most common modelling mistake: emitting an event that means "please do X" (a command). Events describe facts that have already happened; functions decide what to do about them. If you find yourself reaching for present tense, you're modelling a command — consider whether
step.invoke(direct function call) fits better.
Exception: If the producer is a third party that picks its own name (e.g., clerk/user.created, stripe/invoice.paid), keep the third-party name verbatim. Mirroring their convention loses you grep-ability across their docs and your code.
Function ID Stability
Function IDs (the id field in createFunction) are permanent durable keys. Inngest uses them to:
- Match in-flight runs back to the new code after deploy
- Associate step results from a paused run with the next attempt
- Identify the function in the dashboard, alerts, and logs
Why this matters:
If you rename process-order → process-customer-order and deploy, any paused or scheduled runs of the old function are orphaned. They'll never resume. Multi-day workflows (e.g., step.sleep("7d")) silently fail.
Rule: treat function IDs like database primary keys. Pick once, kebab-case, prefer descriptive (charge-and-fulfill-order) over short (order-job). Renaming requires a migration: deploy both names, let the old runs drain, then remove the old.
The friendly name: field, by contrast, is purely cosmetic — change it freely.
Step ID Stability and Idempotency
Inside a function, each step.run("step-id", fn) call is memoized by step ID across attempts. Inngest stores the result in durable storage; on retry or replay, it returns the cached result without re-running the closure.
This has two consequences you have to internalise:
1. Step IDs are durable keys too.
Renaming step.run("charge", ...) → step.run("charge-customer", ...) invalidates the cache for that step. On replay of an existing run, the new step re-executes from scratch — possibly running side effects again. Prefer descriptive IDs from the start; if you must rename, deploy alongside the old name during the migration window.
2. The closure inside step.run may run more than once.
A successful step runs once. A step that throws runs again on the next attempt. A step that runs to completion but the function then crashes before persisting may run again on retry (Inngest aims for at-least-once at the function level; at-most-once requires the step to be idempotent).
Rule: every external side effect inside step.run must be idempotent.
- API calls that mutate state: pass an idempotency key derived from
event.dataorrunId + step-id.
await stripe.charges.create(
{ amount, customer: cust },
{ idempotencyKey: `${runId}:charge` },
);- Database writes: use upserts, not inserts. Or check-then-act on a unique key.
- Sending email/SMS: write a "sent" record under a unique key first, send second. Reads of the record gate further sends.
Exception: read-only steps (getUser, fetchInventory) don't need keys — re-running them is safe. But it costs duplicate API calls, so cache the result.
Code Outside Steps Is Plumbing, Not Logic
The function body re-executes from the top on every step boundary. Inngest replays the deterministic prelude, hits the next un-completed step, runs it, then crashes the process and starts fresh from the top for the step after that.
This means code outside `step.run` runs multiple times. If you do this:
async ({ event, step }) => {
const user = await db.users.find(event.data.id); // bad — runs every replay
await step.run("a", async () => { /* ... */ });
await step.run("b", async () => { /* ... */ });
}…the database fetch happens at least twice (once per step), maybe more. Worse, the result isn't durable — if the DB changes between replays, the two attempts see different user objects.
Rule: every read or write of mutable state goes inside step.run. The function body should contain only:
- Reading
event.data(immutable per run) - Calling
step.* - Cheap pure transforms of step results (joining strings, picking fields)
- Branching/looping based on step results
Why: the same memoization that protects retries from double-side-effects makes step results the only durable values across replays.
One Schema Per Event, Co-Located With the Event
Define the schema in the same file that exports the event symbol:
// src/inngest/events/shop.ts
export const orderPlaced = eventType("shop/order.placed", {
schema: z.object({
orderId: z.string(),
accountId: z.string(),
totalCents: z.number().int().nonnegative(),
}),
});Why decentralized (one file per domain) instead of one big schemas file:
- Inngest's TypeScript SDK v4 explicitly moved away from the centralized
EventSchemaspattern of v3. The current docs and examples all use per-eventeventType(). - Co-location means changing a payload and changing its producers happens in one PR, one diff. With a central schema file the producer is in
app/api/webhook/route.tsand the schema is ininngest/schemas.ts— easy to forget one. - Domain files become the readable inventory of a domain's vocabulary. Open
events/shop.tsto see every event the shop domain emits.
Payloads Carry IDs, Not Whole Objects
Bad:
await inngest.send({
name: "shop/order.placed",
data: { order, customer, items }, // 50KB JSON blob
});Good:
await inngest.send({
name: "shop/order.placed",
data: { orderId: order.id },
});Why:
- Events are durable: every event is stored. Big payloads inflate storage and replay overhead.
- State drifts. A snapshot of
orderfrom event time is stale by the time the function runs. The function should reload fresh state insidestep.run. - Step results are also persisted. Loading the order inside
step.run("load-order", ...)makes that load idempotent and cached for the duration of the run.
Exception: include enough denormalized data to make routing/filtering decisions without an extra DB hit. For example, accountId on every event keyed for per-tenant concurrency.
Function Registry: One File, Imported Once
The route handler imports a single array. Adding a new function = one line in the registry, the route handler never changes:
// src/inngest/functions/index.ts
import { processOrder } from "./process-order";
import { sendWelcomeEmail } from "./send-welcome-email";
import { dailyReport } from "./daily-report";
export const functions = [processOrder, sendWelcomeEmail, dailyReport];// src/app/api/inngest/route.ts
import { functions } from "@/inngest/functions";
import { inngest } from "@/inngest/client";
export const { GET, POST, PUT } = serve({ client: inngest, functions });Why:
- The route handler is high-traffic infrastructure code. You don't want unrelated function edits triggering noise in that file's blame.
- Listing functions in one place gives you a single grep-able inventory.
- Inngest's
serve()registers what you pass. Forgetting to add a function to the route is the most common "why isn't my function running?" — a registry makes it a one-line addition instead of a multi-step process.
Cron vs Event Triggers
Use a cron trigger when the schedule is the source of truth — "every weekday at 9am report yesterday's metrics." Use an event trigger when something happening is the source of truth — "send a welcome email after a user signs up."
Don't simulate one with the other:
- Don't trigger every minute and check "is now within the window?" — Inngest charges per run; bursty work that's mostly no-ops wastes budget.
- Don't fire a manufactured event from external cron and have the function check the time — you've moved the schedule out of Inngest's view and lost the dashboard's visibility.
Cron expression specifics:
- 5 fields:
m h dom mon dow. Standard Unix cron. - Default timezone is UTC. To use another, prefix with
TZ=…:cron("TZ=America/New_York 0 9 * * 1-5"). Inngest handles DST correctly. - Crons emit a synthetic event under the hood. The handler receives
eventbut it has nodata. Don't write code that readsevent.datain a cron-only function.
Combine triggers when you want a scheduled job that can also be kicked off manually (helpful for development):
triggers: [
cron("0 9 * * *"),
reportRequested, // event symbol for ad-hoc invocation
],Fan-Out Patterns
Two distinct shapes. Picking the right one matters because the wrong one either silently drops failures or blows up memory.
Fire-and-forget: step.sendEvent
The orchestrator emits one event per item and returns immediately. Each child run is its own durable function — retries independently, observes independently.
await step.sendEvent(
"dispatch",
items.map((item) => ({ name: "import/contact.requested", data: { id: item.id } })),
);
return { dispatched: items.length };- Scales to millions of items (events are cheap).
- Orchestrator finishes fast; no way to know aggregate success.
- Use when items are independent and the orchestrator doesn't need to act on results.
Wait-for-results: Promise.all(step.invoke(...))
The orchestrator invokes the child function directly and waits for each result. Child runs are sub-workflows of the parent.
const results = await Promise.all(
items.map((item, idx) =>
step.invoke(`child-${item.id}-${idx}`, {
function: importContact,
data: { id: item.id },
timeout: "10m",
}),
),
);- Each
step.invokeis a step — its result is durable and memoized. - The orchestrator's runtime is bounded by the slowest child. A child that retries for hours holds the orchestrator open.
- Concurrency is gated by the child function's
concurrencysetting, not the orchestrator's. - Use when you need to aggregate results (sum totals, collect errors, decide a next action).
- Chunk if
items.length > ~200—Promise.allover thousands of invokes exhausts memory.
Concurrency, Throttle, Rate-Limit, Debounce — Pick the Right One
They sound similar; they solve different problems.
| Control | What it does | When to use |
|---|---|---|
concurrency | Cap how many runs are in flight at once. New runs queue. | Protecting a downstream resource (DB connection pool, third-party API concurrency limit). |
throttle | Cap how many runs start per period. Excess runs queue. | Smoothing a bursty input into a steady rate (e.g., webhook floods). |
rateLimit | Cap how many runs start per period. Excess runs are dropped. | Hard limits where you'd rather skip than queue (free-tier abuse, cost ceilings). |
debounce | Coalesce many same-key events into a single run after a quiet period. | "User edited their profile 12 times in 30s — only run the indexing job once." |
Common mistake: using concurrency to "rate limit" — concurrency doesn't slow down a single fast run, it just caps parallelism. If your downstream is "100 requests per minute," that's a throttle.
All four take a key field for per-tenant isolation:
concurrency: { limit: 5, key: "event.data.accountId", scope: "fn" }…means "5 concurrent runs per accountId, not 5 globally." Without key, one heavy tenant starves everyone.
Dev Server: INNGEST_DEV=1
Local development needs the Inngest dev server. Run it alongside next dev:
npx inngest-cli@latest devIn .env.local:
INNGEST_DEV=1The SDK auto-detects this and points inngest.send and the route handler at the local dev server (http://localhost:8288) instead of Inngest Cloud. You can see runs, inspect step state, manually re-run, and replay from any step in the dev UI.
Why this matters: without INNGEST_DEV=1, the SDK tries to talk to Inngest Cloud. In dev that means either silent no-ops (no event key set) or actual cloud invocations (event key set, which is worse — production traffic touched by local code).
Path Overrides
If your project uses app/ (no src/) or Pages Router, override defaults in config.json:
{
"client_path": "inngest/client.ts",
"route_path": "app/api/inngest/route.ts",
"events_dir": "inngest/events",
"functions_dir": "inngest/functions"
}The templates still work — the placeholder paths in their import statements need to match. The conventions above are unchanged regardless of layout.
Related skills
FAQ
What does inngest-nextjs-patterns do?
inngest-nextjs-patterns is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use inngest-nextjs-patterns?
When you need to helps with ai & agent building tasks during ai-assisted development, or when inngest-nextjs-patterns is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
inngest-nextjs-patterns; AI & Agent Building; AI-coding skill.