npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

which-url

v0.0.13

Published

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

Readme

which-url

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

This README documents [email protected].

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