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:

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-storage

Each 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.

ConcernPackageInstallsRead next
Database@beignet/provider-db-drizzledb plus per-backend Drizzle helpers via /sqlite, /postgres, and /mysqlDatabase and transactions
Cache@beignet/provider-cache-rediscache, plus redis escape hatchCache
Search@beignet/provider-search-meilisearchsearch, plus meilisearch escape hatchSearch
Storage@beignet/provider-storage-local, @beignet/provider-storage-s3, @beignet/provider-storage-vercel-blobstorage, plus s3Storage/vercelBlob provider escape hatchesStorage
Mail@beignet/provider-mail-resend, @beignet/provider-mail-smtpmailer, plus resend or smtp escape hatchMail
Payments@beignet/provider-payments-stripepayments, plus stripe escape hatchPayments and billing
Webhooks@beignet/provider-webhooks-github, @beignet/provider-webhooks-stripeGitHub and generic Stripe verifiers for webhook routesWebhooks
Feature flags@beignet/provider-flags-openfeatureflags, plus openFeature escape hatchFeature flags
Error reporting@beignet/provider-error-reporting-sentryerrorReporter, plus sentry escape hatchError reporting
Locks@beignet/provider-locks-redislocks, plus redisLocks escape hatchLocks and leases
Logger@beignet/provider-logger-pinologgerLogging
Rate limiting@beignet/provider-rate-limit-upstashrateLimit, plus upstash escape hatchRate limiting
Event bus@beignet/provider-event-bus-memory, @beignet/provider-event-bus-rediseventBus, plus redisEventBus escape hatch for RedisEvents
Auth@beignet/provider-auth-better-authauthAuthentication
Jobs@beignet/provider-jobs-bullmq, @beignet/provider-jobs-inngestjobs, plus bullMQJobs or inngest escape hatchJobs

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:

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.