Runtime recipes
Beignet keeps runtime work behind explicit entrypoints. The web process serves HTTP. Cron routes trigger one bounded unit of work. Workers consume queues or repeat bounded commands. Provider-backed function hosts run provider-owned invocations. Tasks run when an operator, CI job, or release pipeline asks for them.
That separation is intentional: providers install ports and manage clients, but they should not start unbounded polling loops, queue consumers, or drain intervals during server boot. Put repeated work in the runtime that owns its scaling, shutdown, health checks, and credentials.
Runtime matrix
Choose the smallest process layout that matches the work your app actually does:
| If your app uses | Run this | Beignet surface |
|---|---|---|
| Contracts, auth, UI, OpenAPI, devtools, uploads, storage routes, and webhooks | One web process | createApiRoute(getServer), createNextServer(...), or createFetchServer(...) |
| Best-effort cross-process events | Web processes with the Redis event bus provider registered | ctx.ports.eventBus, redisEventBusProvider |
| Durable events or outbox-backed jobs | Web process plus a cron drain route or drain command | createOutboxDrainRoute(...), beignet outbox drain |
| BullMQ-backed jobs | Web process plus a worker process | createBullMQJobWorker(...) |
| Inngest-backed jobs | Web process plus the Inngest route or function host | createInngestJobFunction(...), serve(...) |
| Schedules | Web process plus a protected cron route, scheduled function, or command scheduler | createScheduleRoute(...), beignet schedule run <name> |
| Backfills, repairs, imports, exports, and release maintenance | One-off command | beignet task run <name> |
Use a single web process when all work is request/response. Add a cron route when work needs an HTTP-triggered scheduler. Add a worker process when work is long-running, repeated, or queue-backed. Add a provider function host only when the provider owns invocation and retry semantics.
Web process
The web process answers traffic only. It owns API routes, auth callbacks, webhooks, OpenAPI, devtools when enabled, uploads, storage routes, and process-local health endpoints.
In Next.js, expose the central API from a catch-all route:
// app/api/[[...path]]/route.ts
import { createApiRoute } from "@beignet/next";
import { getServer } from "@/server";
export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
createApiRoute(getServer);For Bun, Deno, Cloudflare Workers, Node fetch servers, and other Web Fetch
runtimes, use @beignet/web and serve the server.fetch handler from the host
that owns the process.
Do not start queue consumers, outbox polling loops, schedule intervals, or long-running workers from the web process in serverless deployments. A container web process may make outbound provider calls while handling requests, but background repetition should still live in a worker, cron, or command runtime.
Liveness and readiness
Keep liveness and readiness separate:
/api/healthis cheap and local. It answers whether the process can return a response./api/readyruns bounded dependency checks. It answers whether the process should receive traffic.
Generated apps expose both endpoints. A readiness route can use
createHealthRoute(...) from @beignet/next and provider-owned
checkHealth() helpers:
// app/api/ready/route.ts
import { createHealthRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
export const { GET } = createHealthRoute(
getServer,
{
checks: {
database: (ports) => ports.db.checkHealth(),
cache: (ports) => ports.redis.checkHealth(),
search: (ports) => ports.meilisearch.checkHealth(),
storage: (ports) => ports.s3Storage.checkHealth(),
},
timeoutMs: 2000,
},
env.NODE_ENV,
);Only include checks for providers your app actually installs. Readiness probes
should be cheap and non-mutating: select 1, Redis PING, queue metadata,
search health, storage bucket access, or a provider health endpoint. Do not
run migrations, outbox drains, schedule handlers, queue consumers, imports, or
backfills from readiness routes.
First-party providers expose these useful readiness helpers:
| Provider area | Port or escape hatch | Probe |
|---|---|---|
| Drizzle database | ctx.ports.db | checkHealth() |
| Redis cache | ctx.ports.redis | checkHealth() |
| Redis event bus | ctx.ports.redisEventBus | checkHealth() |
| Redis locks | ctx.ports.redisLocks | checkHealth() |
| BullMQ jobs | ctx.ports.bullMQJobs | checkHealth({ timeoutMs }) |
| Meilisearch | ctx.ports.meilisearch | checkHealth() |
| S3-compatible storage | ctx.ports.s3Storage | checkHealth() |
For worker readiness, expose the same kind of bounded check from the worker host or run it before accepting work. A BullMQ worker, for example, should prove Redis and the app database are reachable before it starts claiming jobs.
Cron and schedules
Use cron routes when the deployment platform owns the schedule trigger. In
Next.js apps, createScheduleRoute(...) keeps the route small: it
authenticates with CRON_SECRET, runs the schedule through the server request
pipeline, records instrumentation, and returns a status:
// app/api/cron/digests/daily-digest/route.ts
import { createScheduleRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { schedules } from "@/server/schedules";
export const runtime = "nodejs";
export const { GET, POST } = createScheduleRoute({
server: getServer,
schedules,
schedule: "digests.send-daily",
secret: env.CRON_SECRET,
source: "vercel-cron",
});Use beignet schedule run when the host can run a command instead of calling
HTTP, such as a release job, container worker, CI job, or scheduler with
command support:
beignet schedule run digests.send-daily --scheduled-at 2026-01-01T09:00:00.000ZSchedules are trigger definitions. When missed or failed work needs durable retry and dead-letter behavior, keep the schedule handler small and enqueue a job or write an outbox message.
Outbox drains
Use createOutboxDrainRoute(...) for deployments where a platform cron invokes
HTTP. The route should drain one bounded batch and exit:
// app/api/cron/outbox/drain/route.ts
import { createOutboxDrainRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { outboxRegistry } from "@/server/outbox";
export const runtime = "nodejs";
export const { GET, POST } = createOutboxDrainRoute({
server: getServer,
registry: outboxRegistry,
secret: env.CRON_SECRET,
batchSize: 100,
});Use the CLI when the host can run a bounded command:
beignet outbox drain --batch-size 100Both paths use the same server/outbox.ts registry. The HTTP route creates
context through the server request pipeline; the CLI path uses
createOutboxDrainContext(...) from server/outbox.ts to build an app service
context. A long-running worker may repeat bounded drain passes, but the loop
belongs to the worker host, not provider lifecycle hooks.
Workers
Worker processes own long-running or repeated background work. They can run queue consumers, repeated outbox drains, or command schedulers, and they should still call Beignet primitives internally so app context, ports, logging, audit, and devtools instrumentation remain consistent.
For BullMQ, consume an explicit list of Beignet job definitions with
createBullMQJobWorker(...):
// server/workers/jobs.ts
import { createBullMQJobWorker } from "@beignet/provider-jobs-bullmq";
import { SendWelcomeEmailJob } from "@/features/users/jobs";
import { createBackgroundContext } from "@/infra/background-context";
export const jobsWorker = createBullMQJobWorker({
queueName: process.env.BULLMQ_QUEUE_NAME ?? "beignet-jobs",
redisUrl: process.env.BULLMQ_REDIS_URL ?? "redis://localhost:6379/0",
prefix: process.env.BULLMQ_PREFIX ?? "beignet",
jobs: [SendWelcomeEmailJob],
ctx: () => createBackgroundContext(),
workerOptions: {
concurrency: 5,
},
errorReporter: ({ ctx }) => ctx.ports.errorReporter,
});Give workers the same provider environment they need to build app ports, plus worker-specific settings such as queue Redis URLs, concurrency, and shutdown timeouts. Handle process shutdown by closing workers so the provider stops claiming new work and gives active work a chance to finish.
Prefer the outbox when work must commit atomically with database writes. Prefer provider-backed jobs when the provider should own queueing, scheduling, invocation, and retry behavior.
Provider-backed functions
Inngest owns queueing, invocation, and retry behavior for direct Inngest-backed
jobs. The Beignet provider installs ctx.ports.jobs for dispatch and exposes
helpers that map Beignet job definitions to Inngest functions:
// app/api/inngest/route.ts
import { createInngestJobFunction } from "@beignet/provider-jobs-inngest";
import { serve } from "inngest/next";
import { SendWelcomeEmailJob } from "@/features/users/jobs";
import { createBackgroundContext } from "@/infra/background-context";
import { inngest } from "@/infra/inngest";
const sendWelcomeEmail = createInngestJobFunction({
client: inngest,
job: SendWelcomeEmailJob,
ctx: () => createBackgroundContext(),
});
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmail],
});Treat provider-backed functions as worker entrypoints, not web request handlers. They need the same service-context decisions as other background work: actor, tenant, app ports, logging, audit, and failure reporting.
Best-effort events
The Redis event bus provider gives cross-process Pub/Sub delivery while subscribers are online. Every process that should receive an event must register its listeners in that process. If a web process publishes an event and no worker is subscribed, Redis does not replay the missed message later.
Use Redis Pub/Sub for best-effort notifications, cache invalidation, or in-process fan-out where missed messages are acceptable. Use the outbox or a job provider for workflow-critical side effects that need persistence, retry, acknowledgement, or dead-letter state.
One-off tasks
Use app tasks for backfills, repairs, imports, exports, and release maintenance:
beignet task run posts.backfill-search --input '{"dryRun":true}'Tasks should call use cases and ports rather than reaching into infra
directly. Prefer CLI entrypoints over exposed HTTP routes for work that
operators or CI trigger by hand. Pass --tenant when the app's
createTaskContext(...) resolves operational tenant scope separately from the
task input payload.
Runtime checklist
Before shipping a runtime topology, confirm:
- The web process only serves HTTP and health routes.
- Every cron route or operational HTTP route is protected with a secret or app-owned authorization.
- Every scheduled or drain invocation does one bounded unit of work.
- Every worker has readiness checks for the dependencies it must reach before claiming work.
- Provider lifecycle hooks install ports, prepare clients, and close resources, but do not start unbounded loops.
- Best-effort event buses are not used for workflow-critical delivery.
- Durable side effects use outbox rows, jobs, idempotency, or provider-owned retry semantics.
Related pages
- Going to production for preflight checks, secrets, host settings, and production hardening.
- Schedules for schedule definitions and cron route behavior.
- Outbox for transactional delivery, retries, and dead-letter state.
- Jobs for BullMQ and Inngest provider behavior.
- Tasks for operational task definitions and CLI execution.
- Providers for lifecycle and setup order.