Providers
Providers are startup-time adapters. They install concrete ports for databases,
caches, storage, mail, payments, feature flags, auth, logging, jobs, rate limits, and other
external services while handlers and use cases depend only on ctx.ports.
Read Ports and adapters first if you want the dependency boundary. Read the production feature pages when you want a task-specific guide, and Writing a provider when you are building a reusable provider package.
How providers fit
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { createPinoLoggerProvider } from "@beignet/provider-logger-pino";
import { createRedisCacheProvider } from "@beignet/provider-cache-redis";
import { appPorts } from "@/infra/app-ports";
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: appPorts,
providers: [createPinoLoggerProvider(), createRedisCacheProvider()],
context: ({ ports }) => ({
requestId: crypto.randomUUID(),
ports,
}),
}),
);Provider-installed ports are available in context factories, route handlers,
hooks, use cases, and server.ports.
Generated apps keep provider wiring in two places:
infra/app-ports.tsbinds app-owned ports such as the policy gate and declares the rest as deferred provider-contributed keys withdefinePorts<AppPorts>()({ bound, deferred }).server/providers.tsregisters runtime providers in startup order, exportedas constso port types can be inferred from the list.
App-owned infra providers, such as a database provider that wires repositories,
belong under infra/ and are registered from server/providers.ts after the
provider that installs the lower-level port they need.
For common first-party production providers, let the CLI apply the wiring:
beignet providers add mail-resend
beignet providers add search-meilisearch
beignet providers add redis-cache
beignet providers add event-bus-redis
beignet providers add s3-storageEach preset updates dependencies, server/providers.ts, AppPorts,
infra/app-ports.ts, .env.example, and docs/integrations.md. Use
--dry-run --json to review the exact changes before writing, then run
beignet providers audit and beignet doctor --strict. Choose one provider per
app-facing port: for example, use mail-resend or mail-smtp, not both.
After all providers have started, the server verifies that every deferred port
was contributed and fails boot with the missing keys otherwise. See
Defer ports to providers for the
onUnboundPorts options.
Typed provider ports
InferProviderPorts extracts and merges the ports a provider list contributes,
so app code can type ctx.ports without hand-written casts:
// app-context.ts
import type { InferProviderPorts } from "@beignet/core/providers";
import type { AppPorts } from "@/ports";
import type { providers } from "@/server/providers";
export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
export type AppContext = {
requestId: string;
ports: AppRuntimePorts;
};The import of providers is type-only, so app-context.ts stays free of
runtime server dependencies.
App-local providers can declare the ports they require from earlier providers,
plus their app context and service-context input, through the curried
createProvider<Requires, Context, ServiceInput>() form:
import { createProvider } from "@beignet/core/providers";
import type { DbPort } from "@beignet/provider-db-drizzle/sqlite";
import type { AppContext } from "@/app-context";
import type { AppServiceContextInput } from "@/server";
import type { AppPorts } from "@/ports";
import type * as schema from "./schema";
export const appDatabaseProvider = createProvider<
{ db: DbPort<typeof schema> },
AppContext,
AppServiceContextInput
>()({
name: "app-database",
async setup({ ports }) {
const providedPorts: Pick<AppPorts, "posts" | "uow"> = {
...createRepositories(ports.db.drizzle),
uow: createUnitOfWork(ports.db.drizzle),
};
return { ports: providedPorts };
},
});Annotate the returned ports with a Pick<AppPorts, ...> of the keys the
provider fulfills. Writing a provider covers the typing
guidance for setup results and lifecycle hooks in detail.
Naming conventions
Provider exports follow a small naming rule:
// Provider factories with env-backed defaults need no options
createRedisCacheProvider()
createPinoLoggerProvider()
createSmtpMailProvider()
// Provider factories accept app-owned runtime input and return a provider
createDrizzleSqliteProvider({ schema })
createAuthBetterAuthProvider(auth)
createMemoryEventBusProvider()
// Direct port factories return concrete implementations for manual wiring
createMemoryEventBus()
createMemoryMailer()Use createXProvider(...) for Beignet lifecycle providers registered with
providers: []. Provider packages expose factories rather than shared
singleton instances, so each server composition owns its provider object. Use
createXPort() or a domain-specific factory name for direct implementations
assigned under ports.
Webhook verifier packages are still provider packages, but they do not install
ports. They adapt vendor signature rules to @beignet/core/webhooks and are
passed at the route/server boundary, usually through createWebhookRoute(...).
When a vendor also has a full app-facing port, prefer that capability provider
for that workflow; for example Stripe billing uses
@beignet/provider-payments-stripe and createPaymentWebhookRoute(...), while
@beignet/provider-webhooks-stripe is for generic Stripe inbound events.
Provider vs port factory
A port factory is just app code that returns one concrete port. It is the right shape for simple dependencies, tests, and one-off adapters.
import { createMemoryMailer } from "@beignet/core/mail";
import { definePorts } from "@beignet/core/ports";
export const appPorts = definePorts({
logger: fallbackLogger,
mailer: createMemoryMailer(),
});A provider participates in server startup. Use one when infrastructure needs configuration loading, setup order, startup checks, teardown, provider instrumentation, or reusable packaging.
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: appPorts,
providers: [createPinoLoggerProvider(), createSmtpMailProvider()],
context: appContextBlueprint,
}),
);Setup order
Providers run in the order you pass them to the server. Each provider sees base ports plus ports returned by earlier providers.
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: appPorts,
providers: [
createPinoLoggerProvider(), // installs ctx.ports.logger
createRedisCacheProvider(), // can see ctx.ports.logger during setup
],
context: appContextBlueprint,
}),
);When two providers return the same port key, the later provider wins. Use that deliberately for environment-specific overrides.
Lifecycle
setup runs during server creation. start runs after all providers have
contributed ports. stop runs when the server is stopped.
Provider lifecycle hooks should do bounded resource work: create clients,
install ports, run startup checks, and close resources. Do not start polling
loops, queue consumers, or other unbounded background work from setup or
start in serverless apps. Put background work behind explicit runtime
entrypoints such as cron routes, scheduled handlers, job functions, or worker
processes. See Runtime recipes for the process layouts and
readiness checks those entrypoints should use.
import { createProvider } from "@beignet/core/providers";
import { z } from "zod";
const CacheConfigSchema = z.object({
URL: z.string().url(),
});
export const cacheProvider = createProvider({
name: "cache",
config: { schema: CacheConfigSchema, envPrefix: "CACHE_" },
async setup({ config }) {
const client = await connectToCache(config.URL);
return {
ports: {
cache: {
get: (key) => client.get(key),
set: async (key, value, options) => {
if (options?.ttlSeconds) {
await client.set(key, value, { ttlSeconds: options.ttlSeconds });
} else {
await client.set(key, value);
}
},
delete: async (key) => client.delete(key),
has: async (key) => (await client.exists(key)) > 0,
remember: async (key, factory, options) => {
const cached = await client.get(key);
if (cached != null) return cached;
const value = await factory();
await client.set(key, value, options?.ttlSeconds);
return value;
},
},
},
async stop() {
await client.close();
},
};
},
});The envPrefix strips the prefix before validation. For example,
CACHE_URL=redis://localhost:6379 becomes { URL: "redis://localhost:6379" }.
Escape hatches
First-party providers expose stable app-facing ports for normal use and raw clients as escape hatches for provider-specific features.
await ctx.ports.mailer.send({
to: "user@example.com",
subject: "Welcome",
text: "Hello",
});
await ctx.ports.resend.client.emails.send({
from: "sender@example.com",
to: "user@example.com",
subject: "Invoice",
html: "<p>Attached.</p>",
attachments: [{ filename: "invoice.pdf", content: pdfBuffer }],
});Application code should prefer the stable port. Use the escape hatch only when the provider has a feature the port intentionally does not model. Each capability page lists the escape-hatch port its providers install.
First-party providers
Provider packages are named provider-<capability>-<implementation>. When an
implementation spans multiple database backends, each backend is a subpath
export: the Drizzle package ships @beignet/provider-db-drizzle/sqlite,
/postgres, and /mysql, with database drivers as optional peer dependencies
so apps install only the driver they use.
| Concern | Package | Installs | Read next |
|---|---|---|---|
| Database | @beignet/provider-db-drizzle | db plus per-backend Drizzle helpers via /sqlite, /postgres, and /mysql | Database and transactions |
| Cache | @beignet/provider-cache-redis | cache, plus redis escape hatch | Cache |
| Search | @beignet/provider-search-meilisearch | search, plus meilisearch escape hatch | Search |
| Storage | @beignet/provider-storage-local, @beignet/provider-storage-s3, @beignet/provider-storage-vercel-blob | storage, plus s3Storage/vercelBlob provider escape hatches | Storage |
@beignet/provider-mail-resend, @beignet/provider-mail-smtp | mailer, plus resend or smtp escape hatch | ||
| Payments | @beignet/provider-payments-stripe | payments, plus stripe escape hatch | Payments and billing |
| Webhooks | @beignet/provider-webhooks-github, @beignet/provider-webhooks-stripe | GitHub and generic Stripe verifiers for webhook routes | Webhooks |
| Feature flags | @beignet/provider-flags-openfeature | flags, plus openFeature escape hatch | Feature flags |
| Error reporting | @beignet/provider-error-reporting-sentry | errorReporter, plus sentry escape hatch | Error reporting |
| Locks | @beignet/provider-locks-redis | locks, plus redisLocks escape hatch | Locks and leases |
| Logger | @beignet/provider-logger-pino | logger | Logging |
| Rate limiting | @beignet/provider-rate-limit-upstash | rateLimit, plus upstash escape hatch | Rate limiting |
| Event bus | @beignet/provider-event-bus-memory, @beignet/provider-event-bus-redis | eventBus, plus redisEventBus escape hatch for Redis | Events |
| Auth | @beignet/provider-auth-better-auth | auth | Authentication |
| Jobs | @beignet/provider-jobs-bullmq, @beignet/provider-jobs-inngest | jobs, plus bullMQJobs or inngest escape hatch | Jobs |
Provider packages
Reusable provider packages carry conventions beyond the runtime object: a
static beignet.provider metadata manifest in package.json that
beignet doctor reads, provider instrumentation so external work appears in
devtools, and explicit durable-workflow semantics for providers that
participate in jobs, events, schedules, or outbox delivery.
Run beignet providers audit for a report-only inventory of installed provider
metadata, registration, env, tables, and app ports; JSON output also includes
active variants and watchers.
Run beignet providers add <preset> when you want the CLI to install and wire
one of the stable presets (flags-openfeature, mail-resend, mail-smtp,
search-meilisearch, sentry, upstash-rate-limit, redis-cache,
event-bus-redis, redis-locks, s3-storage, or vercel-blob-storage)
instead of copying the setup recipe by hand.
First-party provider READMEs follow the same setup shape: install, env, wiring,
installed ports, escape hatches, instrumentation, failure behavior, local/test
substitutes, and deployment notes. Providers that touch remote dependencies
should either document a fail-fast stance with caller-owned retries, or expose
bounded retry configuration. Mail providers are fail-fast because sends are not
idempotent and retries belong in jobs or the outbox; the S3 provider delegates
bounded transient retries to the AWS SDK.
Providers with cheap, non-mutating dependency probes expose explicit
checkHealth() helpers on their provider-owned ports or escape hatches for
app-owned /api/ready routes.
Writing a provider covers all of these.
Process boundaries of memory providers
Memory ports — cache, rate limit, locks, search, event bus, payments — keep
their state in the process that created them. A seed script and a dev server
are different processes, so state written by one is invisible to the other:
documents a seed script indexes into createMemorySearchProvider() do not
exist in the dev server's search port, and cache entries or rate-limit
counters written in one process never appear in another.
When a workflow depends on derived state such as a search index:
- Rebuild the derived state inside the serving process. Run a boot or backfill
task against the server's own ports — for example an
issues.backfill-searchtask that re-indexes every issue — or keep the state current with sync listeners driven by the outbox. - Swap the memory provider for a shared-store provider in the provider list:
@beignet/provider-search-meilisearchfor search,@beignet/provider-locks-redisfor locks, or@beignet/provider-rate-limit-upstashfor rate limits. Every process then reads and writes the same store. - Never treat a memory provider as a fixture that a separate process can preload. If a script must prepare state for the server, that state has to live in a shared store the server also reads.
Testing
For tests, pass mock or memory ports directly instead of booting production providers:
const testPorts = definePorts({
posts: createInMemoryPostRepository(),
mailer: createMemoryMailer(),
logger: {
info: () => {},
error: () => {},
},
});Handlers and use cases still receive ctx.ports, so production and test code
paths stay the same.