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.
The principle
Section titled “The principle”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 pattern (TypeScript / Vite reference)
Section titled “The pattern (TypeScript / Vite reference)”The minimum viable shape:
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:
# .env.local — checked into the repo as .env.local.example, never as .env.localVITE_STRIPE_BASE_URL=http://localhost:4111VITE_STRIPE_KEY=sk_test_anything # twins don't enforce real keysIn production, leave VITE_STRIPE_BASE_URL unset. The SDK falls back to the real Stripe API. Same code path, different runtime configuration.
Beyond one service: a registry
Section titled “Beyond one service: a registry”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:
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:
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 safety —
ServiceNamemakes 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/servicespage 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).
Reference implementations
Section titled “Reference implementations”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.
Anti-patterns
Section titled “Anti-patterns”- Hardcoding twin URLs in committed code. The twin URL belongs in env config, never in source.
- No fallback default. If
VITE_STRIPE_BASE_URLis 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.
TODO / coming later
Section titled “TODO / coming later”- 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)