Registry API for interchangeable services | Next.js Supabase Turbo
Understand Makerkit's typed registry helper and reuse it to swap implementations across environments.
Makerkit ships with a small but powerful registry helper at packages/shared/src/registry/index.ts
.
The helper wraps a map of lazy factories and a coordinated setup queue, so we can decide which implementation to load only when the app needs it.
This keeps the monorepo flexible without exploding the number of conditionals or circular imports - with implementations that can be swapped at runtime using environment variables.
This is useful when you want to provide different implementations of a feature based on the environment.
We use this pattern extensively for:
- billing providers (ex. Stripe, Lemon Squeezy, etc.)
- monitoring providers (ex. Sentry, Signoz, etc.)
- CMS providers (ex. Prismic, Sanity, etc.)
- Mailers (ex. Resend, Mailgun, etc.)
- and more...
Why we use a registry
- Pluggable infrastructure: many features (billing, monitoring, CMS, mailers) can target different SaaS providers. The registry lets each package expose a single typed entry point while shipping multiple implementations side-by-side.
- Lazy loading: factories only run during
get(...)
, so optional features do not bloat the JavaScript bundle or initialize connections until required. - Type-safe ergonomics: the generic signature ensures that
register('stripe', ...)
andget('stripe')
stay in sync. Asking for an unknown implementation fails fast with a descriptive error. - Coordinated setup:
addSetup
andsetup
allow packages to group async initialization work (for example, seeding demo data or configuring third-party SDKs) without leaking details to consumers.
Core API surface
The helper returns a Registry
object with four methods:
register(name, factory)
stores an async factory for that implementation and returns the registry for chaining.get(...names)
waits for any registered setup tasks, then resolves one or many implementations. Passing multiple names yields a tuple that keeps the original order.addSetup(group, callback)
queues initialization callbacks under a named group.setup(group?)
executes all registered setup groups (or a single group) exactly once.
Because ImplementationFactory<T>
can return either T
or Promise<T>
, you can register synchronous helpers or full async imports with the same signature.
Example: selecting a billing gateway at runtime
import { createRegistry } from '@kit/shared/registry';import type { BillingGateway } from './types';type BillingProvider = 'stripe' | 'lemon-squeezy';const billingRegistry = createRegistry<BillingGateway, BillingProvider>();billingRegistry .register('stripe', async () => { const { createStripeGateway } = await import('./stripe-gateway'); return createStripeGateway(); }) .register('lemon-squeezy', async () => { const { createLemonSqueezyGateway } = await import('./lemon-squeezy-gateway'); return createLemonSqueezyGateway(); });export async function getBillingGateway() { const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe'; return await billingRegistry.get(provider);}
Calling getBillingGateway()
will resolve only the factory that matches the active provider, keeping the rest of the codebase decoupled from the concrete implementation.
Reusing the pattern in your own packages
Follow these steps whenever you need interchangeable implementations in your app:
- Model the interface you expect the implementation to expose and the union of valid provider names.
- Instantiate a registry with
createRegistry<Interface, ProviderUnion>()
inside the package that owns the feature. - Register implementations close to their code. Use dynamic
import()
to avoid bundling providers that are not in use. - Expose a helper (for example,
getCmsClient()
or a React provider) that reads configuration and callsregistry.get
. Consumers stay unaware of the registry details. - Optionally add setup hooks if your providers require shared initialization (e.g. registering cron jobs or verifying credentials). Group them with
addSetup('billing', callback)
and trigger them viaawait registry.setup('billing')
from your bootstrapping code.
Because the registry is just TypeScript, you can reuse it outside Makerkit — drop the helper into any project where you want to ship multiple drop-in strategies without rewriting your public API.