Skip to content

Running your app against local twins

You have a twin running on localhost:4111. Now your application code needs to point at it in dev, while pointing at the real vendor in production. This is the substitution pattern — and it’s the core of integrating WonderTwin cleanly.

For each external service your app talks to, the base URL is the variable. Everything else — API surface, SDK calls, auth shape — stays identical. The twin speaks the real vendor’s protocol.

So the wiring boils down to: read base URL from environment; default to the real vendor; override with the twin URL in dev/staging.

The minimum viable shape:

src/lib/stripe.ts
import Stripe from 'stripe'
const baseURL =
import.meta.env.VITE_STRIPE_BASE_URL || 'https://api.stripe.com'
export const stripe = new Stripe(import.meta.env.VITE_STRIPE_KEY, {
host: baseURL,
})

Then in your dev environment:

Terminal window
# .env.local — checked into the repo as .env.local.example, never as .env.local
VITE_STRIPE_BASE_URL=http://localhost:4111
VITE_STRIPE_KEY=sk_test_anything # twins don't enforce real keys

In production, leave VITE_STRIPE_BASE_URL unset. The SDK falls back to the real Stripe API. Same code path, different runtime configuration.

When your app talks to 3+ twinned services, the per-file import.meta.env.VITE_<SERVICE>_BASE_URL pattern starts to fragment. A central registry collapses it:

src/services/registry.ts
export const SERVICES = {
stripe: {
real: 'https://api.stripe.com',
envBaseUrl: 'VITE_STRIPE_BASE_URL',
envKey: 'VITE_STRIPE_KEY',
},
posthog: {
real: 'https://us.i.posthog.com',
envBaseUrl: 'VITE_POSTHOG_HOST',
envKey: 'VITE_POSTHOG_KEY',
},
// …
} as const
export type ServiceName = keyof typeof SERVICES
export function serviceUrl(name: ServiceName): string {
const cfg = SERVICES[name]
return import.meta.env[cfg.envBaseUrl] || cfg.real
}
export function isTwinned(name: ServiceName): boolean {
return Boolean(import.meta.env[SERVICES[name].envBaseUrl])
}

Each per-service wrapper then reads from the registry instead of hardcoding env names:

src/lib/stripe.ts
import { serviceUrl } from '../services/registry'
import Stripe from 'stripe'
export const stripe = new Stripe(import.meta.env.VITE_STRIPE_KEY, {
host: serviceUrl('stripe'),
})

Benefits the registry gives you:

  • Single inventory — one place lists every external service your app touches and which are twinable.
  • Type safetyServiceName makes invalid service names a compile error.
  • Build-time validation — a small script can assert every service in the registry has both env vars actually wired in your deploy pipeline (catches “added a wrapper, forgot the deploy step”).
  • Debug visibility — render a /admin/services page in dev/staging showing resolved URLs and twin-or-real status (see hosted-non-prod.md).
  • Production safety — the registry is the natural place to enforce “never resolves to a twin URL in a prod build” (see prod-no-twins.md).

WonderTwin’s own apps dogfood this pattern. The current implementations are small and good starting points to copy:

  • Hardcoding twin URLs in committed code. The twin URL belongs in env config, never in source.
  • No fallback default. If VITE_STRIPE_BASE_URL is unset, your code should default to the real vendor — not crash. Production must work without any twin env vars present.
  • Stringly-typed service names. Without the registry’s type, serviceUrl('stipe') (typo) silently returns the default for an unknown service. The registry pattern makes this a compile error.
  • Skipping the production safety check. Even with disciplined config, accidents happen. Add the explicit check from prod-no-twins.md.
  • Go reference implementation
  • Python reference implementation
  • Patterns for SDKs that don’t accept a base-URL override (HTTP proxying via localhost)
  • Patterns for services with non-HTTP protocols (gRPC, WebSocket twins)
  • Multi-tenant / per-request twin selection (advanced; uncommon)