Empty API Path vs /api Path — How It Resolves, With Example
The most important page in this guide. It documents the shared
calculateApiUrl() function that resolves an API base URL in the browser, and a real
regression that was caught and fixed in this exact function on the same day this page was written.
The Function
Lives at bizfirst-common-js/src/utils/index.ts, re-exported from
@bizfirst/common-js:
bizfirst-common-js/src/utils/index.tsexport function calculateApiUrl(value?: string): string {
const trimmed = (value ?? '').trim()
if (/^(https?:)?\/\//i.test(trimmed)) return trimmed
if (!trimmed) return typeof window === 'undefined' ? '' : window.location.origin
const normalizedPath = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
if (typeof window === 'undefined') return normalizedPath
return `${window.location.origin}${normalizedPath}`
}
Its job is narrow and deliberate: decide which origin to talk to. It does
not decide the API path itself — every endpoint constant across the codebase (e.g.
/api/v1/atlas/forms/list in api-client-js/src/form.client.ts, or
/api/v1/project/projects/list in flow-studio-api/src/config/apiEndpoints.ts)
already carries its own leading /api/v1/... segment. The final request URL is always
calculateApiUrl(value) + endpointConstant, built by a separate buildUrl()
helper that does a plain string concatenation.
Walking Through All Four Input Cases
1. Unset / empty — VITE_API_BASE_URL=
Resolves to the bare current origin, with nothing appended. On a page loaded at
https://qa.grippingly.com/flowstudio/, calculateApiUrl(undefined) returns
exactly https://qa.grippingly.com.
The API call still ends up in the right place because the endpoint constant supplies the
/api/v1/... segment itself. A call to the data-template endpoint composes as:
calculateApiUrl(undefined) // "https://qa.grippingly.com"
+ "/api/v1/ai/template/data-template/by-type"
= "https://qa.grippingly.com/api/v1/ai/template/data-template/by-type"
— which matches the real API. The /api prefix comes from the endpoint constant, not
from calculateApiUrl's default.
2. Relative path, explicitly set — VITE_API_BASE_URL=/custom-api
Also resolves against window.location.origin, giving
https://qa.grippingly.com/custom-api. This is an escape hatch for deployments where a
reverse proxy expects a different prefix than what the endpoint constants assume by default — whoever
sets this value is responsible for making sure it actually matches what's in front of the API,
because it changes what gets prepended in front of every endpoint constant's own
/api/v1/... path.
3. Protocol-relative — VITE_API_BASE_URL=//other-host/path
Passed through unchanged, exactly like a full absolute URL. This form (starting with //,
no explicit scheme) lets the browser inherit whichever scheme the page itself was loaded with. It is
matched by the same regular expression as http:// / https:// values, so it is
never treated as a same-origin relative path.
4. Absolute — VITE_API_BASE_URL=https://api.example.com
Returned unchanged. Used for independent deployments, where the frontend's own origin has no API behind it at all — see Chapter 1.6.
Decision Tree
http(s):// or //?"} Q1 -->|"yes"| Absolute["return unchanged
(absolute or protocol-relative)"] Q1 -->|"no"| Q2{"empty after trim?"} Q2 -->|"yes"| Bare["return window.location.origin
(bare, no suffix)"] Q2 -->|"no"| Normalize["prefix with / if missing"] Normalize --> Relative["return window.location.origin + path"]
Every branch resolves WHICH ORIGIN to call. The actual /api/v1/... path always comes from the endpoint constants, never from this function.
A Real Regression: the Doubled /api/api Bug
An earlier version of this function defaulted the empty case to
window.location.origin + '/api' instead of the bare origin. That looked reasonable in
isolation, but every endpoint constant in the codebase already hardcodes its own leading
/api/v1/... segment, and the URL builder (buildUrl() in
apiEndpoints.ts) does a naive concatenation with zero de-duplication:
export const buildUrl = (baseUrl: string, endpoint: string): string => {
const cleanBaseUrl = baseUrl.replace(/\/$/, '')
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
return `${cleanBaseUrl}${cleanEndpoint}`
}
With the old default, an empty VITE_API_BASE_URL on
https://qa.grippingly.com produced a base URL of
https://qa.grippingly.com/api. Concatenated with an endpoint constant like
/api/v1/atlas/forms/list, the final request became
https://qa.grippingly.com/api/api/v1/atlas/forms/list — a 404, in exactly the
same-origin inline scenario this whole change was meant to make effortless. It was caught by an
independent code review before shipping, and the default was corrected to return the bare origin
with no suffix.
The lesson for anyone touching this function again: never add a default path suffix
here. This function's only job is choosing an origin; path composition belongs entirely to the
endpoint constants and buildUrl(). If a default suffix is ever reintroduced, watch for
doubled /api/api segments in the Network tab exactly as described in
Chapter 3.2.
Call Sites Fixed to Use This Function
These files previously (at various points) risked hardcoded URLs like
'https://localhost:10001' leaking into production builds, or threw fail-fast errors on a
legitimately-empty base URL. All now route through calculateApiUrl():
atlas-forms/packages/api-client-js/src/config.tsatlas-forms/packages/client-js/src/atlas-forms.client.tsflow-studio/packages/flow-studio-api/src/config/apiConfig.tsflow-studio/apps/flow-studio/src/main.tsxflow-studio/apps/flow-studio/src/App.tsx— also removed a fail-fastthrow new Error(...)that used to fire wheneverVITE_API_BASE_URLwas missing. An empty value is now a legitimate, expected configuration (the inline case), not an error.flow-studio/packages/flow-studio-designer/src/components/Modules/EdgeStreamPageRenderer.tsx
Why This Is Better Than a Baked-In Relative String
Because calculateApiUrl() reads window.location.origin in the
browser, at page-load time — not something Vite substitutes once at build time — a single
inline-deployment build can be deployed to any same-origin domain without a rebuild. Deploy
the exact same dist/ output to qa.grippingly.com,
staging.grippingly.com, or grippingly.com, and each one correctly resolves
its own origin — something even a build-time-baked relative string couldn't do across genuinely
different domains, since a relative string still has to be reasoned about relative to
something, and here that "something" is resolved fresh, live, by the browser itself, every
time the page loads.