Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

which-url

Auto-detect your app's URL across hosting providers. Zero config.

This README documents which-url@0.0.13.

npm install which-url
import { origin } from 'which-url'

auth({ baseURL: origin })
fetch(`${origin}/api/data`)
              origin                                env
Local         http://localhost:3000                  "local"
Preview       https://myapp-git-feat.vercel.app     "preview"
Production    https://myapp.com                     "production"

Works across environments (local, preview, production), browser bundles, Node/Bun servers, and edge runtimes that expose compatible env vars through process.env or build-time public env replacement.

For browser bundles, framework-prefixed env vars must be referenced statically so the bundler can inline them at build time. which-url includes literal references such as process.env.NEXT_PUBLIC_APP_URL and process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL; dynamic lookup like process.env["NEXT_PUBLIC_" + name] cannot be inlined by Next.js.

The default export gives you everything as an object:

import appUrl from 'which-url'

appUrl.origin       // "https://myapp.com"
appUrl.productionOrigin // "https://myapp.com" or undefined
appUrl.hostname     // "myapp.com"
appUrl.protocol     // "https:"
appUrl.env          // "production"
appUrl.platform     // "vercel"
appUrl.isResolved   // true
appUrl.isProduction // true

The problem

Your app's base URL shows up everywhere — OAuth callbacks, API calls, CORS, emails. Every one of these breaks if the URL is wrong:

// Auth — needs the exact URL for OAuth redirects
auth({ baseURL: ??? })

// API calls from the client
fetch(`${???}/api/data`)

// Emails — links need to point somewhere real
`Click here to verify: ${???}/verify?token=${token}`

// CORS — needs to know its own origin
cors({ origin: ??? })

Most teams end up with a helper that grows over time:

// lib/url.ts — every team has one of these
function getBaseUrl() {
  if (typeof window !== 'undefined') return ''
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
  return `http://localhost:${process.env.PORT ?? 3000}`
}

// But wait — VERCEL_URL is the deployment URL, not your domain.
// And it doesn't work on the client. So you add more:
const baseUrl =
  process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
    ? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
    : process.env.NEXT_PUBLIC_VERCEL_URL
      ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
      : `http://localhost:${process.env.PORT ?? 3000}`

// And then Netlify uses different env vars. And Cloudflare uses different ones.
// And someone forgets the https://. And preview URLs break in production...

How it works

Reads environment variables that hosting providers set automatically:

  1. APP_URL env var (your override — always wins)
  2. Provider auto-detection (Vercel, Netlify, etc.)
  3. window.location.origin (browser fallback)
  4. http://localhost:${PORT || 3000} (development fallback)

When you call whichUrl({ env }), the passed object replaces process.env as the source for steps 1–2.

If nothing is detected in production, the default singleton returns empty current-URL strings and isResolved: false so imports stay safe in tests, client bundles, and build tools. Call whichUrl() directly when a missing current URL should throw.

origin is the current environment origin. In a preview deployment, it should point at the preview. In production, it should point at production. Locally, it should point at local dev.

productionOrigin is the canonical production origin when configured or detectable, otherwise undefined. Use it for things that should still point at the public production site from previews, such as canonical metadata, social cards, and "view live site" links. When you need a URL in all environments, use productionOrigin ?? origin.

Strict mode with whichUrl()

Use the default export or named constants for convenience:

import { origin } from 'which-url'

Use whichUrl() for production-critical configuration like auth, CORS, emails, and webhooks:

import { whichUrl } from 'which-url'

const appUrl = whichUrl()

auth({ baseURL: appUrl.origin })

whichUrl() resolves fresh environment values when called. If no URL can be detected in production, it throws with instructions to set APP_URL. (createUrl is a deprecated alias from earlier releases.)

If you intentionally use the import-safe singleton in production code, branch on isResolved before trusting URL strings. WhichUrl is a discriminated union on isResolved, so TypeScript narrows the fallback arm away:

import appUrl from 'which-url'

if (!appUrl.isResolved) {
  throw new Error('APP_URL is required in production')
}

auth({ baseURL: appUrl.origin }) // narrowed: resolved, non-empty

For runtimes that pass env as an argument (e.g. Cloudflare Workers), call whichUrl({ env }) and the passed object replaces process.env for that resolution. See Cloudflare Workers below.

Override with APP_URL

Set APP_URL when auto-detection isn't enough — custom domains, tunnels, or unsupported providers:

# .env.local
APP_URL=https://myapp.com

Works with or without protocol (APP_URL=myapp.comhttps://myapp.com).

Client-side frameworks: All framework prefixes are supported automatically — NEXT_PUBLIC_APP_URL, VITE_APP_URL, PUBLIC_APP_URL, NUXT_ENV_APP_URL, etc. These are build-time values in browser bundles.

Production URL

Use APP_PRODUCTION_URL when the canonical production URL is different from the current environment URL:

APP_PRODUCTION_URL=https://myapp.com
import { origin, productionOrigin } from 'which-url'

origin           // current environment origin
productionOrigin // canonical production origin, or undefined

On Vercel, productionOrigin is detected from VERCEL_PROJECT_PRODUCTION_URL, even in preview deployments.

In Next.js browser bundles on Vercel deployments, it is detected from NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL. Vercel adds these framework-prefixed vars for production and preview deployments based on the framework preset, but vercel env pull does not create the prefixed local versions automatically.

Client-side frameworks: Framework prefixes are supported here too — NEXT_PUBLIC_APP_PRODUCTION_URL, VITE_APP_PRODUCTION_URL, PUBLIC_APP_PRODUCTION_URL, etc. These are build-time values in browser bundles.

Allow-lists with allowedOrigins and allowedHostnames

Frameworks and libraries keep asking for the same thing under different names — Next.js allowedDevOrigins, better-auth trustedOrigins, images.remotePatterns, CORS. And in dev the answer is often several URLs at once: with portless you can be reachable on myapp.localhost, a Tailscale URL, and an ngrok URL simultaneously.

allowedOrigins is every origin that is your app in the current environment — primary first (allowedOrigins[0] === origin), deduped. allowedHostnames is the same list reduced to unique hostnames. Both work identically in production, where the list is just your real origins (e.g. custom domain + deployment URL on Vercel).

import { allowedOrigins } from 'which-url'

// better-auth
betterAuth({ trustedOrigins: allowedOrigins })

// CORS
cors({ origin: allowedOrigins })

For the full Next.js wiring (allowedDevOrigins, images.remotePatterns, Server Actions), see Next.js setup below.

What lands in the list:

Environment allowedOrigins
Local dev http://localhost:3000
Local dev + portless https://myapp.localhost, http://localhost:{PORT}
… + --tailscale/--funnel Tailscale URL first, then the above
… + --ngrok ngrok URL first, then the above
Vercel preview branch URL, production domain, deployment URL
Vercel production production domain, deployment URL

On the unresolved import-safe fallback (isResolved: false), both are [].

Next.js setup

Complete wiring for a Next.js app. Each config knob wants a different shape — this table is the contract (coding agents: use these exact mappings, don't hardcode hostnames):

Knob which-url source Expected shape
allowedDevOrigins (next.config) allowedHostnames hostnames, no protocol
images.remotePatterns allowedHostnames{ hostname } pattern objects
experimental.serverActions.allowedOrigins allowedOriginsnew URL(o).host host[:port], no protocol
better-auth baseURL origin full origin
better-auth trustedOrigins allowedOrigins full origins with protocol
metadataBase productionOrigin ?? origin URL instance
// next.config.ts
import type { NextConfig } from 'next'
import { allowedOrigins, allowedHostnames } from 'which-url'

const nextConfig: NextConfig = {
  // Dev server accepts requests from proxied hostnames (portless, tunnels).
  // Dev-only option; ignored in production builds.
  allowedDevOrigins: allowedHostnames,

  images: {
    // Lets next/image load images the app references by its own absolute URLs.
    // Only needed if you do that; harmless otherwise.
    remotePatterns: allowedHostnames.map((hostname) => ({ hostname })),
  },

  experimental: {
    serverActions: {
      // Server Actions reject requests whose Origin header doesn't match the
      // Host header — which is exactly what happens behind a proxy or tunnel.
      allowedOrigins: allowedOrigins.map((o) => new URL(o).host),
    },
  },
}

export default nextConfig
// lib/auth.ts — better-auth
import { betterAuth } from 'better-auth'
import { whichUrl } from 'which-url'

const appUrl = whichUrl() // strict: throws in production if no URL is detectable

export const auth = betterAuth({
  baseURL: appUrl.origin,
  trustedOrigins: appUrl.allowedOrigins,
})
// app/layout.tsx — canonical URLs and social cards point at production, even from previews
import type { Metadata } from 'next'
import { productionOrigin, origin, isResolved } from 'which-url'

export const metadata: Metadata = {
  metadataBase: isResolved ? new URL(productionOrigin ?? origin) : undefined,
}

Notes:

  • next.config.ts runs in Node.js when the server starts, so the plain named imports are correct there — no whichUrl() needed. Under portless/tunnels the env vars are already set because portless wraps the dev command (portless run next dev).
  • Values are resolved when the server starts. If you add a tunnel or change env vars, restart dev.
  • Nothing here needs to change between dev, preview, and production — that is the point. Deploy the same config everywhere.

Multi-tenant apps

For subdomain-based tenants, hostname is often enough when the current app host is also the tenant suffix:

import { hostname } from 'which-url'

const tenantSuffix = hostname

If your app origin and tenant suffix differ — for example app.foo.com for the product UI and *.foo.com for tenants — keep that suffix in your own env var for now. which-url does not currently expose a separate tenant suffix.

Platform support

Platform Detection URL source Verified
Vercel VERCEL VERCEL_PROJECT_PRODUCTION_URL / VERCEL_BRANCH_URL / VERCEL_URL [x]
Netlify NETLIFY URL / DEPLOY_PRIME_URL / DEPLOY_URL [ ]
Cloudflare Pages CF_PAGES CF_PAGES_URL [ ]
Railway RAILWAY_PUBLIC_DOMAIN RAILWAY_PUBLIC_DOMAIN [ ]
Fly.io FLY_APP_NAME {app}.fly.dev [ ]
Render RENDER RENDER_EXTERNAL_URL [ ]
DigitalOcean DIGITALOCEAN_APP_PLATFORM APP_URL [ ]
Heroku HEROKU_APP_NAME HEROKU_APP_DEFAULT_DOMAIN_NAME / {app}.herokuapp.com [ ]

On the client, Vercel's framework-prefixed env vars (NEXT_PUBLIC_VERCEL_URL, VITE_VERCEL_URL, etc.) are detected automatically.

Help us verify: If you're using one of these providers, open an issue with the output of import appUrl from 'which-url'; console.log(appUrl) from your deployment. We'll mark it as verified.

Examples

import { origin, hostname, isProduction } from 'which-url'

// Better Auth
betterAuth({ baseURL: origin })

// API calls
fetch(`${origin}/api/data`)

// CORS
cors({ origin })

// Cookies
cookie.domain = hostname

// Environment checks
if (isProduction) {
  enableAnalytics()
}

Gotchas

origin is an origin — paths are dropped

origin, hostname, etc. describe the app's origin, not an arbitrary URL. If you set APP_URL=https://example.com/base, the /base path is discarded and origin becomes https://example.com. Keep base paths in your own routing config; which-url only resolves where the app lives, not where it's mounted.

Vercel: Redeploy after assigning a custom domain

Framework-prefixed env vars like NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL are inlined into the bundle at build time — the bundler replaces references with their literal values. If you assign a custom domain after deploying, the old deployment still has the previous URL baked in. Trigger a new deployment for the updated domain to take effect.

Advanced

Portless

Zero config — portless sets PORTLESS_URL and which-url picks it up automatically.

"dev": "portless run next dev"
origin → https://myapp.localhost

When sharing publicly, the publicly-reachable URL takes priority for origin: PORTLESS_TAILSCALE_URL (set by --tailscale and --funnel), then PORTLESS_NGROK_URL (set by --ngrok), then PORTLESS_URL. All active portless URLs are included in allowedOrigins.

Tunnels (ngrok, Cloudflare Tunnel)

Tunnel URLs can't be auto-detected — they're external to the app process. Set APP_URL:

APP_URL=https://abc123.ngrok-free.app npm run dev

Cloudflare Workers

Cloudflare Workers pass config through the env argument to fetch, not process.env. Pass it to whichUrl({ env }):

import { whichUrl } from "which-url"

export default {
  async fetch(request: Request, env: Env) {
    const appUrl = whichUrl({ env })

    return Response.json({ origin: appUrl.origin, env: appUrl.env })
  },
}
# wrangler.toml
[vars]
APP_URL = "https://api.example.com"
APP_ENV = "production"

Non-string bindings (KV, Durable Objects, R2, service bindings) are ignored automatically — only string [vars] participate in URL detection.

⚠️ The default singleton (import appUrl from "which-url") and named exports (origin, env, etc.) resolve at module load, before your fetch handler runs. On Workers they will not see your [vars]. Use whichUrl({ env }) from inside your handler.

If you're on nodejs_compat and prefer process.env, the default singleton works too — but whichUrl({ env }) is the runtime-native path and doesn't require the compat flag.

Debugging

import appUrl from 'which-url'

console.log(appUrl.debug)
// "[provider:vercel] url=myapp.com | env=production (vercel:production)"
// "[override] APP_URL=https://custom.com | env=production (NODE_ENV=production)"
// "[portless] PORTLESS_URL=https://myapp.localhost | env=local (NODE_ENV=development)"
// "[fallback] PORT=3004 | env=local (NODE_ENV=development)"

API

Default export

An import-safe singleton object with URL properties and environment helpers. It resolves once when the package is imported. Typed as WhichUrl, a discriminated union on isResolved — narrowing on it removes the empty-string fallback arm.

whichUrl()

Strict resolver function. It resolves when called and throws if no URL can be detected. Returns ResolvedWhichUrl — no isResolved check needed. createUrl remains as a deprecated alias.

Named exports

Export Type Example
origin string "https://myapp.vercel.app"
hostname string "myapp.vercel.app"
host string "myapp.vercel.app" or "localhost:3000"
protocol string "https:"
port string "" or "3000"
productionOrigin string | undefined "https://myapp.com" or undefined
allowedOrigins string[] ["https://myapp.com", "https://myapp.vercel.app"]
allowedHostnames string[] ["myapp.com", "myapp.vercel.app"]
env AppEnv "production" | "preview" | "local"
platform Platform "vercel" | "netlify" | ... | null
debug* string "[provider:vercel] url=myapp.com | env=production (vercel:production)"
isResolved boolean true unless the import-safe fallback was used
isProduction boolean
isPreview boolean
isLocal boolean
whichUrl (options?: { env?: object }) => ResolvedWhichUrl strict resolver
createUrl deprecated alias of whichUrl

* debug is non-enumerable on the default export and objects returned by whichUrl() — excluded from JSON.stringify to avoid React hydration mismatches. It is also available as a named export.

License

MIT

About

Auto-detect your app's URL in local, preview and production on any platform or framework.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages