PRACTICAL GUIDE / Playwright baseURL path resolution
The slash that sends Playwright to the wrong route
Predict every relative navigation from Playwright's baseURL, catch trailing-slash mistakes, and keep routes and waits aligned across environments.
In this guide7 sections
- Resolve paths the way a browser does
- Choose root-relative and directory-relative paths on purpose
- Make the trailing slash a validated configuration choice
- Apply the same rule to navigation, routing, and waits
- Diagnose the URL before waiting on the page
- Migrate a suite without changing every path blindly
- Use another approach when the base is not shared
What you will learn
- Resolve paths the way a browser does
- Choose root-relative and directory-relative paths on purpose
- Make the trailing slash a validated configuration choice
- Apply the same rule to navigation, routing, and waits
A login test reaches the right host and the wrong application path, then waits for a heading that will never appear. The site is mounted under /qa/, but page.goto('/login') silently targets the origin root. The locator timeout is only the last symptom; one leading slash caused the failure.
Playwright does not concatenate baseURL and a path as strings. It uses the web platform's URL constructor. That means root-relative paths, directory-relative paths, parent segments, query strings, and trailing slashes keep their standard URL meaning. Once that mechanism is explicit, most "works locally, 404s in staging" failures become predictable before a browser starts.
Resolve paths the way a browser does
The test options documentation states that Playwright considers baseURL for page.goto(), page.route(), page.waitForURL(), page.waitForRequest(), and page.waitForResponse(). It builds the corresponding URL with new URL(). Page URL assertions using a string also merge it with the context's base URL.
new URL(reference, base) is resolution, not concatenation. The base contributes its scheme, host, port, path, query, and fragment according to URL rules. An absolute reference supplies its own origin and ignores the base. A reference beginning with / keeps the base origin but starts at its root. A reference beginning with ./ starts at the base URL's current directory.
The current directory depends on the final slash. Given https://qa.example.test/app/, the directory is /app/. Given https://qa.example.test/app, the final segment looks like a file named app, so the current directory is /. The MDN URL constructor reference documents this behavior independently of Playwright.
These examples are exact URL outcomes, not measurements:
| baseURL | Reference | Resolved URL |
|---|---|---|
https://qa.example.test/app/ | ./login | https://qa.example.test/app/login |
https://qa.example.test/app/ | login | https://qa.example.test/app/login |
https://qa.example.test/app/ | /login | https://qa.example.test/login |
https://qa.example.test/app/ | ../login | https://qa.example.test/login |
https://qa.example.test/app | ./login | https://qa.example.test/login |
https://qa.example.test/app/index.html | ./login | https://qa.example.test/app/login |
The difference between login and ./login is small for these bases. Teams often prefer ./login because it makes directory-relative intent visible during review. The critical distinction is between either of those and /login.
Query-only and fragment-only references also resolve rather than append arbitrary text. With a base ending in /app/, ?mode=review targets /app/?mode=review; #details targets the same base document with a fragment. If the test means /app/orders?mode=review, state the path and query together. Relying on a query-only reference can accidentally target the base directory instead of the route currently open in a human's mental model.
Write a table-driven mechanism test for the URL contract your deployment relies on. It runs without launching a browser, and every expected value is independently stated. Changing a base path or losing a slash makes a relevant row fail. Then add a configuration-bound case that reads Playwright's baseURL test fixture, which exposes the resolved use.baseURL option. The example below pins separate approved local and CI values. Replace the illustrative CI host with the reviewed target for your own environment.
import { test, expect } from '@playwright/test';
const cases = [
{
baseURL: 'https://qa.example.test/app/',
reference: './login',
expected: 'https://qa.example.test/app/login',
},
{
baseURL: 'https://qa.example.test/app/',
reference: '/login',
expected: 'https://qa.example.test/login',
},
{
baseURL: 'https://qa.example.test/app',
reference: './login',
expected: 'https://qa.example.test/login',
},
{
baseURL: 'https://qa.example.test/app/',
reference: '../health',
expected: 'https://qa.example.test/health',
},
] as const;
for (const { baseURL, reference, expected } of cases) {
test(`${reference} resolves from ${baseURL}`, () => {
expect(new URL(reference, baseURL).href).toBe(expected);
});
}
const approvedBaseURL = process.env.CI
? 'https://qa.example.test/qa/'
: 'http://127.0.0.1:3000/qa/';
test('configured baseURL matches the approved deployment', async ({ baseURL }) => {
if (!baseURL)
throw new Error('This project requires use.baseURL');
expect(baseURL).toBe(approvedBaseURL);
expect(new URL('./login', baseURL).href).toBe(
new URL('./login', approvedBaseURL).href,
);
});The fixed rows explain URL mechanics, while the final case reads the value Playwright actually configured. Its expected CI URL is source-controlled rather than copied from APP_BASE_URL, so changing that CI variable makes the assertion fail unless the reviewed contract changes with it. These tests do not prove the routes exist. A browser test must still assert the server response and product page. Keeping the layers separate tells you whether a failure came from configuration, URL construction, or application behavior.
Choose root-relative and directory-relative paths on purpose
A root-relative path is correct when the route truly belongs at the origin root in every environment. Health endpoints, a centralized identity callback, or a platform landing page may intentionally live at /health, /oauth/callback, or /. In those cases, the leading slash documents that the application prefix should be discarded.
A directory-relative path is correct when the application can be deployed under a prefix. Preview environments, reverse proxies, documentation sites, and multi-tenant gateways often mount a build at /qa/, /preview/123/, or /products/admin/. ./login preserves that directory when the configured base ends with /.
The wrong form can pass locally because the local application is mounted at root. Both http://127.0.0.1:3000/login and a human's intended local route are identical there. Staging adds /qa/, exposing the assumption. A useful regression project includes at least one environment or local proxy that exercises the path prefix before release.
Take a settings test as a worked example. Local configuration uses http://127.0.0.1:3000/, while a preview deployment uses https://preview.example.test/build-418/. The reference ./settings resolves to /settings locally and /build-418/settings in preview. The same spec reaches the application in both places. The reference /settings also passes locally, but it escapes the preview directory and reaches https://preview.example.test/settings. That preview failure is evidence of root-relative intent in the test, not evidence that Chromium handles staging differently.
Now add authentication. A request to the wrong root route might redirect to a valid root login page rather than return 404. The final page contains a "Sign in" heading, so a loose assertion can pass even though the test never reached the preview application. Assert the resolved settings URL before the action, then assert the expected redirect destination including its prefix and return parameter. A generic login heading cannot prove which application issued it.
A second example has two legitimate path owners on one origin. The admin UI lives under /products/admin/, while a shared API lives at /api/. UI navigation should use ./users from an admin base ending in /. API waits and mocks should use /api/users because the API is origin-root owned. Converting both strings to the same style would break one contract. The correct review question is who owns the target path, not whether the string begins with a slash.
A third example starts from a document-like base such as https://host/app/index.html. Resolving ./reports produces /app/reports because the last segment is treated as the current document. Changing the configuration to https://host/app without a slash changes the current directory to /, even though a person may read both values as "the app path." This is why a config guard should describe whether the base is a directory or document instead of normalizing every value mechanically.
These examples also explain why screenshots alone are weak path evidence. A shared login shell, branded 404 page, or SPA fallback can look correct at several URLs. Preserve the address, status, and route-specific marker. The visual artifact becomes useful after those fields identify which application surface it depicts.
Do not fix the issue by blindly removing every leading slash. Some paths are intentionally root-relative. Inventory navigations by ownership. A route inside the deployed application should usually be directory-relative. A route owned by the platform root should remain root-relative. A full external URL should be explicit.
Page objects can encode that decision in method names. openAppRoute('./orders') and openOriginRoute('/health') are more reviewable than a helper called open(path) that accepts anything. The helper should still call new URL() or pass the reference to Playwright, not join strings with /.
String concatenation creates its own bugs. ${baseURL}/login can produce double slashes, preserve an unwanted filename segment, mishandle query strings, or turn a missing scheme into a confusing value. Normalizing with replace(/\/+$/, '') also erases the difference between a base directory and a base file. Let the URL standard do the work and validate the configuration contract around it.
Parent-relative paths deserve extra scrutiny. ../admin may be correct from /app/, but a future base move to /products/app/ changes the result to /products/admin. That coupling is easy to miss. Prefer a root-relative reference when the target is platform-root owned, or give the second application its own explicit base URL.
Absolute URLs are sometimes the clearest option. A test that deliberately moves from the UI host to an external identity host should say so. The base URL is ignored for an absolute reference, which prevents an environment prefix from being applied accidentally. Store trusted environment origins in configuration rather than embedding production hosts in specs.
Make the trailing slash a validated configuration choice
An environment variable is where the slash most often disappears. One CI variable contains https://preview.example.test/qa/, another contains https://staging.example.test/qa, and only one preserves directory-relative routes. Treat the trailing slash as schema, not formatting.
If the base represents an application directory, reject a value whose pathname does not end in /. Failing during config load is better than allowing every navigation to target the parent directory. The following configuration keeps local and remote values under one rule and configures the web server separately from path resolution.
import { defineConfig } from '@playwright/test';
const suppliedBaseURL = process.env.APP_BASE_URL;
if (process.env.CI && !suppliedBaseURL)
throw new Error('APP_BASE_URL is required in CI');
export const appBaseURL = suppliedBaseURL ?? 'http://127.0.0.1:3000/qa/';
const parsedBaseURL = new URL(appBaseURL);
if (!parsedBaseURL.pathname.endsWith('/')) {
throw new Error(
`APP_BASE_URL must identify an application directory and end in '/': ${appBaseURL}`,
);
}
export default defineConfig({
testDir: './tests',
webServer: suppliedBaseURL
? undefined
: {
command: 'pnpm dev',
url: 'http://127.0.0.1:3000/qa/',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: appBaseURL,
trace: 'retain-on-failure',
},
});This guard is intentionally strict. Automatically appending / would convert https://host/app into https://host/app/, but the original value may have meant a page or endpoint named app. Silent repair hides an ambiguous contract. Ask the environment owner to supply a directory URL explicitly.
A root deployment still passes the guard because a parsed origin such as https://host has pathname /. Tests can use ./login, which resolves to /login. The same test preserves /qa/ when that prefix is present. That consistency is the main benefit of directory-relative application routes.
Do not assume webServer.url automatically becomes the test baseURL. The web server guide recommends setting use.baseURL explicitly. The readiness URL answers whether a process can accept requests. The base URL answers how test references resolve. Multiple web servers make that separation mandatory because the runner cannot guess which one owns page navigation.
Readiness can use a dedicated endpoint under the same deployment. If /qa/ redirects to login but /qa/health returns a stable response, configure the health URL for readiness and keep /qa/ as baseURL. They need not be textually identical. They must describe the same environment intentionally.
Project overrides can change baseURL for a subset of tests. A mobile project and desktop project may share it, while an admin application uses another prefix. Keep route helpers inside the owning project. A test imported into two projects with different bases should be written to work under both or excluded explicitly; accidental cross-project discovery produces path failures that look environmental.
Apply the same rule to navigation, routing, and waits
Changing page.goto() without changing route mocks and waits can leave a suite half-migrated. A navigation reaches /qa/orders, while page.route('/api/orders') resolves from the origin root and never intercepts /qa/api/orders. The page waits on a real backend, and the resulting timeout gets blamed on the application.
For string patterns that do not start with *, page and context routing resolve against baseURL with new URL(). A glob such as **/api/orders is a glob pattern rather than a relative URL. A regular expression or predicate evaluates requests by its own logic. Pick the form that states the contract you want.
Use ./api/orders when the API is inside the application directory. Use /api/orders when the API is at the origin root. Use a full URL when it belongs to a different origin. Use **/api/orders only when matching that path across multiple origins or prefixes is genuinely acceptable. A broad glob can hide a request going to the wrong host.
The same distinction applies to waitForResponse(). Register the wait before the action that triggers the request. Then assert the response URL and status rather than treating Promise resolution as success. The following spec aligns the mock, navigation, wait, and final DOM assertion under one directory-relative contract.
import { test, expect } from '@playwright/test';
test('loads orders from the application-scoped API', async ({ page }) => {
const configuredBaseURL = test.info().project.use.baseURL;
if (typeof configuredBaseURL !== 'string')
throw new Error('This project requires use.baseURL');
const expectedPageURL = new URL('./orders', configuredBaseURL).href;
const expectedApiURL = new URL('./api/orders', configuredBaseURL).href;
await page.route('./api/orders', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 'order-42', status: 'ready' }]),
});
}, { times: 1 });
const responsePromise = page.waitForResponse('./api/orders');
const navigationResponse = await page.goto('./orders');
const apiResponse = await responsePromise;
expect(navigationResponse).not.toBeNull();
expect(navigationResponse!.url()).toBe(expectedPageURL);
expect(navigationResponse!.ok()).toBeTruthy();
expect(apiResponse.url()).toBe(expectedApiURL);
expect(apiResponse.ok()).toBeTruthy();
await expect(page.getByRole('row', { name: /order-42 ready/i })).toBeVisible();
});The assertions cover separate boundaries, with one deliberate limitation. A route moved to the origin root breaks the expected API URL. Because the route handler fulfills the request with status 200, apiResponse.ok() confirms only that mocked response; it cannot expose a server-side response or server error. A UI regression breaks the row assertion. The test does not present the mocked API status as evidence about backend health.
Be careful with application-relative requests. A browser fetch('./api/orders') resolves against the document URL, not directly against Playwright configuration. When the document is /qa/orders, the browser's URL rules happen to produce /qa/api/orders. If the document route ends in /qa/orders/, the same relative fetch produces /qa/orders/api/orders. The application and test harness both use URL semantics, but their bases may be different documents.
For APIs with a stable root location, make the application request explicit and match that contract in Playwright. For APIs intentionally colocated with a route directory, preserve the directory structure in tests. Do not assume one ./api convention fits both.
URL assertions deserve the same review. expect(page).toHaveURL('./orders') resolves the string with baseURL. A regular expression does not acquire the base prefix automatically. Regex is useful for variable IDs or query order, but it can become too permissive. A predicate receiving a URL object can assert origin, pathname, and search parameters independently without ignoring the deployment path.
Diagnose the URL before waiting on the page
When a locator times out after navigation, read the navigation response and final page URL first. page.goto() returns the main resource response for an HTTP navigation. The Page API states that valid HTTP status codes such as 404 and 500 do not make navigation throw. A wrong path can therefore complete successfully from the network's point of view and fail only at the next locator.
Log the configured base, input reference, resolved expectation, response URL, response status, and final page URL. These are observations from the run, not estimated measurements. Attach them to the failing test if the report does not already make them visible.
import { test, expect } from '@playwright/test';
test('opens the public login route', async ({ page }, testInfo) => {
const configuredBaseURL = testInfo.project.use.baseURL;
if (typeof configuredBaseURL !== 'string')
throw new Error('This project requires use.baseURL');
const reference = './login';
const expectedURL = new URL(reference, configuredBaseURL).href;
const response = await page.goto(reference);
if (!response)
throw new Error(`Navigation to ${reference} returned no HTTP response`);
await testInfo.attach('navigation-resolution', {
body: JSON.stringify({
configuredBaseURL,
reference,
expectedURL,
responseURL: response.url(),
status: response.status(),
finalPageURL: page.url(),
}, null, 2),
contentType: 'application/json',
});
expect(response.url()).toBe(expectedURL);
expect(response.ok()).toBeTruthy();
await expect(page).toHaveURL(expectedURL);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});If expectedURL is already wrong, fix the base or reference. If the expected URL is correct but responseURL differs, inspect redirects. Authentication middleware may redirect a protected route to login, a proxy may strip or add a prefix, or the application may canonicalize a trailing slash. Follow the redirect chain from the response request when that distinction matters.
If URL and status are correct but the heading is missing, the path mechanism is no longer the leading suspect. Inspect the DOM, console, application request failures, and feature state. Separating that case prevents a team from changing slashes in a test that reached the right product page.
If the response is 404 at the origin root while the configured base contains a prefix, search for a leading slash in the navigation or route. If the response is 404 one directory above the prefix, inspect the trailing slash on baseURL. Those two signatures look similar in a locator error and resolve differently.
A single-page application's fallback can hide both. The server may return the same HTML shell with status 200 for every path. The URL assertion passes, navigation ok() passes, and a generic app heading appears. Assert a route-specific marker or application state so /login cannot satisfy a test intended for /qa/login merely because both load the shell.
Service workers and caches are a separate near-miss. A cached shell can make the wrong route look functional or keep an old redirect alive. Compare a clean context, network events, and service-worker configuration before changing baseURL. The resolved URL is deterministic; the content served at that URL can still depend on browser state.
DNS, TLS, and connection failures occur before a valid HTTP response. Those make page.goto() throw and do not resemble a 404 once you inspect the response. Check reachability, certificates, and webServer readiness. Do not add or remove a slash to repair a host that never answered.
Migrate a suite without changing every path blindly
Start by documenting the deployment contract for each environment. Record the origin, application directory, API root, identity origin, and readiness endpoint. Decide which paths should survive a prefix and which should escape to the origin root. This is architecture information, not a search-and-replace rule.
Inventory page.goto, page.route, context.route, waitForURL, waitForRequest, waitForResponse, and string URL assertions. Classify each string as directory-relative, root-relative, absolute, glob, regex, or predicate. Review the ambiguous ones with the application owner. A leading slash may be a bug or the most important character in the contract.
Add pure URL cases for the main deployment shapes before editing browser tests. Include root and prefixed bases, with and without the trailing slash if rejection behavior is part of config. Those tests give reviewers a compact map of intended outcomes, but the pure rows cannot observe a CI variable. Add a configuration-bound case that consumes the baseURL fixture and compares it with an independently reviewed value. That case fails when CI changes the configured deployment without a matching contract update, before a long browser suite reports dozens of locator timeouts. Keep the expected value outside the variable under test; comparing the variable with a value derived from itself would recreate the same false oracle.
Change one feature area at a time. Update navigation, mocks, waits, and URL assertions together. Run the route under a root deployment and a prefixed deployment. A migration that passes only under root has not tested the reason for the change.
Keep environment wiring visible in CI. The following job receives one approved application-directory URL and runs a focused path contract before the broader suite. It does not invent a preview hostname inside the test repository.
name: Playwright path contract
on:
pull_request:
jobs:
base-url:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
APP_BASE_URL: ${{ vars.E2E_APP_BASE_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm through Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Verify URL resolution and application routes
run: pnpm exec playwright test tests/url-contract.spec.ts tests/navigation.spec.ts --project=chromiumValidate that the CI variable is present and ends in / during config load, as the earlier TypeScript does. An unset variable should not silently target a developer default in a remote release job. Local fallback is useful for local development; CI should make the chosen environment obvious in its logs and report metadata.
Expect some helpers to disappear. A custom joinBaseAndPath() often exists because the suite was compensating for misunderstood URL behavior. Replace it with the URL constructor or direct Playwright relative paths, then delete it after callers migrate. Keeping both mechanisms invites future disagreement.
The rollout cost is review time and temporarily broader execution. The benefit is portable navigation across path-prefixed deployments. Do not claim a speed or reliability improvement without run data. The concrete outcome is simpler: every path has a named ownership rule and a test that can fail when configuration violates it.
Use another approach when the base is not shared
Do not force one baseURL across several independent applications. An admin portal, customer app, identity provider, and API may have different origins and path rules. Use explicit configuration fields or separate Playwright projects. A single base plus repeated ../ segments makes tests depend on accidental URL layout.
Prefer absolute URLs for deliberate cross-origin navigation. They make the trust boundary visible in review and ignore the current base by standard URL behavior. Keep environment origins outside test bodies so production endpoints cannot be selected accidentally by a copied string.
Avoid directory-relative paths when the product contract is explicitly root-mounted. /login is clearer if every supported deployment guarantees that route at the origin root. Adding ./ for stylistic consistency would weaken that guarantee by making behavior depend on the base directory.
Do not use baseURL to repair application links. If the page itself renders an anchor with the wrong href, changing the test navigation can hide the defect. Assert the link target and follow it as a user would. Test configuration controls test-supplied paths, not the application's routing contract.
Skip a generic navigation helper that catches 404, rewrites the URL, and tries again. A fallback from /qa/login to /login makes environments appear compatible while testing different deployments. Fail on the first wrong response and fix the route or configuration.
Finally, do not diagnose a correct URL as a path problem because the next locator failed. Once the response URL, status, final page URL, and route-specific marker show the intended page, move to application state, rendering, authentication, or test data. URL resolution is deterministic. Good evidence lets you stop investigating it at the right moment.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does page.goto('./login') drop the last baseURL segment?
A missing trailing slash makes the last baseURL path segment behave like a file during URL resolution. With a base ending in `/app`, `./login` resolves from the parent directory, while a base ending in `/app/` resolves inside the app directory.
What is the difference between /login and ./login in Playwright?
A leading slash resolves from the origin root and discards any path prefix in baseURL. A dot-relative path resolves from the base URL's current directory, so it preserves a prefix when that base ends with a slash.
Does baseURL also affect page.route and waitForResponse?
Yes. The documented baseURL behavior applies to page.goto, page.route, page.waitForURL, page.waitForRequest, and page.waitForResponse by using the URL constructor for eligible string values. Glob and regular-expression matching have their own semantics.
Why does page.goto not fail on a wrong 404 route?
HTTP 404 and 500 responses are valid HTTP responses, so navigation does not throw solely because of their status. Assert the returned response status or `ok()` result, the final URL, and a page marker that belongs to the intended application.
Should webServer.url and use.baseURL be the same?
They often point to the same deployment, but they serve different jobs: webServer.url is a readiness target and use.baseURL resolves test paths. Configure baseURL explicitly, especially with multiple servers or a deployment path prefix.
RELATED GUIDES
Continue the learning route
GUIDE 01
Dispose APIRequestContext Correctly in Large Playwright Suites
Master Playwright APIRequestContext dispose with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Test Passkey Sign-In Failure Paths with Playwright
A practical guide to Playwright passkey sign in negative test cases, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 03
Playwright Page Component Objects for Shared Navigation, Tables, and Modals
Design Playwright page component objects for shared navigation, tables, and modals with semantic locators, fixtures, composition, and focused assertions.
GUIDE 04
How to Take Screenshots in Playwright
Learn how to take screenshots in Playwright for full page, element, and failure captures, plus visual checks and practical CI debugging tips.
GUIDE 05
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.