ScreenshotNeo

BlogEngineering

Why Cypress Adds /__/#/ to URLs Passed to cy.visit()

Cypress’s /__/ runner URL and /#/ hash route come from different places. Learn how baseUrl, relative paths, and app routing determine the final URL.

By the ScreenshotNeo team30 September 20266 min read

Why Cypress Adds /__/#/ to URLs Passed to cy.visit()

Short answer: Cypress does not universally append /__/#/ to URLs passed to cy.visit(). The /__/ path belongs to Cypress’s own runner, which starts on localhost and a random port. The /#/ segment usually comes from your application’s hash-based routing or from a baseUrl that already contains /#/. Relative paths passed to cy.visit() are prefixed with baseUrl; fully qualified URLs select their own host.

What each URL component means

Observed part Usually comes from What it means
http://localhost:<random-port> Cypress runner The local web application that hosts the Cypress command interface.
/__/ Cypress runner An internal runner path documented by Cypress. It is separate from the application under test.
/#/ Your baseUrl or application router A hash route prefix. Cypress only uses it when it is present in the configured URL or supplied route.
/dashboard Your test argument The relative path Cypress resolves against baseUrl.

Cypress describes the initial runner as a localhost URL with a random port and an internal /__/ path. After the first visit, it changes the browser URL to the origin of the remote application so browser same-origin rules can be respected. See Cypress trade-offs and web security.

How Cypress resolves cy.visit()

Relative paths use baseUrl

With this configuration:

The runner URL and the application’s hash route are separate pieces of the navigation flow.
The runner URL and the application’s hash route are separate pieces of the navigation flow.
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000/#/'
  }
});

This test:

describe('dashboard', () => {
  it('opens the dashboard', () => {
    cy.visit('dashboard');
  });
});

resolves to:

http://localhost:3000/#/dashboard

The hash is not being invented by cy.visit(); it is part of baseUrl. Cypress documents baseUrl as the prefix for relative cy.visit() and cy.request() calls. See configuration and the cy.visit() API.

Leading slashes and missing slashes

// Both are relative to baseUrl.
cy.visit('/dashboard');
cy.visit('dashboard');

Keep the route style consistent with your configured base URL. If the base URL contains a hash route, verify how your app expects the fragment to be joined.

Fully qualified URLs bypass the prefix

cy.visit('https://example.test/dashboard');

A fully qualified URL targets the specified origin instead of being appended to baseUrl. Visiting a different origin can require Cypress’s cross-origin features and may need cy.origin(), depending on what the test does after navigation. See Cypress same-origin guidance.

Why the runner URL changes after the first visit

Cypress first loads its own runner so it can display the test UI and execute commands. Once the test calls cy.visit(), Cypress navigates the browser to the application origin. Cypress explains this as a way to work within browser same-origin policy. Therefore, seeing a localhost /__/ URL before the first visit and an application URL afterward is expected.

A relative cy.visit path is resolved by combining it with the configured baseUrl.
A relative cy.visit path is resolved by combining it with the configured baseUrl.

Diagnose the exact URL in your project

  1. Print the configured base URL. Check e2e.baseUrl in cypress.config.js or cypress.config.ts. Look for /__/, /#/, a trailing slash, or a path prefix.
  2. Classify the visit argument. A relative string uses baseUrl; a fully qualified URL chooses its own host.
  3. Separate runner state from application state. A random-port localhost address with /__/ is the runner. The URL after the first visit is the application URL.
  4. Inspect application routing. Hash routers commonly produce paths such as /#/settings without Cypress modifying them.
  5. Derive assertions from configuration. Avoid hardcoding a random Cypress port. Use the configured base URL when constructing expected URLs.
it('uses the configured application origin', () => {
  cy.visit('dashboard');
  cy.location('host').should('eq', new URL(Cypress.config('baseUrl')).host);
});

Common problems and fixes

Symptom Cause Fix
You assert against localhost:random-port The runner port is generated dynamically. Assert against Cypress.config('baseUrl') or the application origin.
/#/ appears twice Both baseUrl and the visit argument include a hash prefix. Keep the hash route in one place, usually baseUrl or the route argument.
The app opens at the wrong path A relative path is being prefixed by an unexpected baseUrl. Log the configuration and use a fully qualified URL when you intentionally need another host.
The first page works but a second origin fails The test crossed an origin boundary. Use Cypress’s documented cross-origin approach, including cy.origin() where required.
Requests target the wrong host baseUrl also affects relative cy.request() calls. Make the request URL explicit or correct baseUrl.
The runner shows /__/ when no page was visited No application navigation has happened yet. Call cy.visit() or configure baseUrl so Cypress starts at the app URL.

Single-page app with hash routing

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000/#/'
  }
});
cy.visit('dashboard'); // http://localhost:3000/#/dashboard

Application with normal paths

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000'
  }
});
cy.visit('/dashboard'); // http://localhost:3000/dashboard

Environment-specific base URLs

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: process.env.APP_URL || 'http://localhost:3000'
  }
});

Keep the origin in configuration and let tests use relative paths. This avoids changing every test when the app moves between local, staging, and CI environments.

Assertions that do not depend on Cypress’s runner URL

it('lands on the expected route', () => {
  cy.visit('dashboard');
  cy.location('pathname').should('eq', '/dashboard');
});

it('checks a hash route', () => {
  cy.visit('dashboard');
  cy.location('hash').should('eq', '#/dashboard');
});

Choose pathname for normal routing and hash for hash routing. This makes the assertion express the application behavior rather than an internal runner detail.

Performance and reliability notes

  • Set baseUrl so Cypress can start from the application URL and resolve relative commands consistently.
  • Use a stable local or test environment URL in CI; changing ports without updating configuration produces misleading URL failures.
  • Prefer route-level assertions over complete-string URL assertions when query strings, ports, or fragments can vary.
  • Use fully qualified URLs only when the test intentionally targets another origin.
  • Do not treat the runner’s /__/ path as an application endpoint. It is an implementation detail of the Cypress runner.

Or skip the browser setup

If your goal is to capture the rendered result rather than drive an interactive Cypress test, ScreenshotNeo provides a direct screenshot request. It handles the page load and returns an image or PDF, so there is no Cypress runner URL to interpret. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Does Cypress always add /#/?

No. It appears when your configured base URL or application routing includes a hash fragment.

What is the difference between /__/ and /#/?

/__/ identifies Cypress’s internal runner path. /#/ is normally an application hash route or part of baseUrl.

Should I remove /__/ from my app URLs?

No. First determine whether you are looking at the runner URL or the application URL. The runner path is not an application route.

Why does cy.visit('/dashboard') differ from cy.visit('https://host/dashboard')?

The first is resolved against baseUrl; the second explicitly selects its host and does not use that prefix.

Where should the hash prefix live?

Keep it in one predictable place. For a hash-routed app, putting it in baseUrl lets tests use simple relative route names.