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 two snippets below are good starting points to copy.

PostHog — env-based, twin in staging, PostHog Cloud in prod

Section titled “PostHog — env-based, twin in staging, PostHog Cloud in prod”
// Browser-side product analytics via PostHog.
//
// Staging routes events through twin-posthog via caddy reverse-proxy
// (set in deploy-staging.yml). Production leaves VITE_WT_POSTHOG_HOST
// unset so the SDK falls back to us.i.posthog.com — but only if a key
// is provided. Empty key = init skipped = silent no-op.
import posthog from 'posthog-js'
let initialized = false
export function initPostHog(): void {
if (initialized) return
const apiKey = import.meta.env.VITE_WT_POSTHOG_API_KEY
if (!apiKey) return // no key configured → no analytics, no errors
posthog.init(apiKey, {
api_host:
import.meta.env.VITE_WT_POSTHOG_HOST || 'https://us.i.posthog.com',
autocapture: true,
capture_pageview: 'history_change',
capture_pageleave: true,
disable_session_recording: true,
})
initialized = true
}
export { posthog }

logo.dev — same pattern, with a vendor-domain map

Section titled “logo.dev — same pattern, with a vendor-domain map”
const DEFAULT_BASE_URL = 'https://img.logo.dev'
const VENDOR_DOMAINS: Record<string, string> = {
logodev: 'logo.dev',
linear: 'linear.app',
bitbucket: 'bitbucket.org',
finch: 'tryfinch.com',
'modern-treasury': 'moderntreasury.com',
qbo: 'quickbooks.intuit.com',
smile: 'smile.io',
'netsuite-bin': 'netsuite.com',
}
export function vendorDomain(slug: string): string {
return VENDOR_DOMAINS[slug] ?? `${slug}.com`
}
export function buildLogoUrl(slug: string): string | null {
const apiKey = import.meta.env.VITE_WT_LOGODEV_API_KEY
if (!apiKey) return null
const baseURL = (
import.meta.env.VITE_WT_LOGODEV_BASE_URL || DEFAULT_BASE_URL
).replace(/\/+$/, '')
return `${baseURL}/${encodeURIComponent(vendorDomain(slug))}?token=${encodeURIComponent(apiKey)}`
}

Both come from wondertwin-web/apps/app/src/lib/ in the WonderTwin source tree. Service-registry package — TODO when the abstraction lands as packages/services; this guide will inline that pattern too.

  • 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)