Appearance
Background Jobs (Inngest)
Inngest runs durable, multi-step background jobs: each step.run() is checkpointed and retried independently on failure, and step.sleep() survives server restarts. This template talks only to a self-hosted Inngest instance (the inngest/inngest Docker image) - never Inngest Cloud.
Local development
- Start the dev server.
docker compose up -dalready starts it (seegetting-started.md) - no extra step needed. It polls your app's/api/inngestroute to discover functions. Not using Docker? Runbun run inngest:dev, orbun run dev:allto run it alongside Next.js viamprocs. - Open the dashboard at http://localhost:8288 to watch runs execute step-by-step, inspect payloads, and replay failed runs.
- Try the built-in demo. Log in, go to
/demo/inngest, click "Trigger Job" - it sends an event via a tRPC mutation (src/features/demo/inngest/server/router.ts), which Inngest picks up and runs throughdemoJob. Watch it execute live on the dashboard. - Functions are registered in
src/app/api/inngest/route.tsviaserve({ client: inngest, functions: [...] })- every new function must be added to that array to be discoverable.
The Inngest client
src/inngest/client.ts:
typescript
export const inngest = new Inngest({
id: env.INNGEST_APP_ID,
middleware: [realtimeMiddleware()],
isDev: true,
});isDev: true is hardcoded intentionally, not a leftover dev setting - the in-code comment explains why: since this app only ever talks to a self-hosted Inngest instance, forcing dev mode regardless of NODE_ENV keeps the SDK from assuming Inngest Cloud and demanding an INNGEST_EVENT_KEY. It targets whatever INNGEST_BASE_URL points at (or localhost:8288 by default). See deployment.md for what this means for production hardening.
Existing jobs
Both defined in src/inngest/functions.ts, triggered by events in the InngestEvents enum:
onUserSignedUp(UserSignedUpevent) - sleeps 1s and returns a welcome message. Stubbed with aTODOfor real logic (sending a welcome email, provisioning resources, etc.) - not currently wired to fire from the sign-up flow itself.demoJob(DemoTriggeredevent) - the/demo/inngestjob. Three steps:fetch-stats(simulated async fetch),processing-delay(astep.sleep("processing-delay", "5s")to demonstrate durable scheduling),generate-report. Publishes live progress at each stage over Inngest Realtime viademoJobChannel(src/inngest/realtime.ts, topic"status"), and publishes afailedstatus before rethrowing on error.
Realtime subscriptions
The demoJob pattern for streaming status to the browser without polling:
- Server publishes updates during the job:
await publish(channel.status({ status, message, ... })). - A tRPC query issues a short-lived subscription token scoped to one channel:
getSubscriptionToken(inngest, { channel, topics: ["status"] })(seedemo/inngest/server/router.ts). - The client subscribes with
useInngestSubscriptionfrom@inngest/realtime/hooks, passing arefreshTokencallback that re-fetches that token.
This requires NEXT_PUBLIC_INNGEST_BASE_URL to be set to a URL the browser can reach - see environment-variables.md.
Adding a new job
Add new functions in src/inngest/functions.ts:
typescript
export const sendWeeklyDigest = inngest.createFunction(
{ id: "send-weekly-digest" },
{ cron: "0 9 * * 1" }, // every Monday at 9am
async ({ step }) => {
const users = await step.run("fetch-users", async () => {
return prisma.user.findMany({ select: { email: true } });
});
// process users...
return { processed: users.length };
},
);Then register it in src/app/api/inngest/route.ts's functions: [...] array.