How to Click Links and Navigate Pages with Puppeteer
Navigate directly with Puppeteer or click a link and reliably wait for the destination. Includes runnable JavaScript, SPA readiness, troubleshooting, and a screenshot API option.
Use page.goto(url) when you already know the destination. When a link click should navigate to another document, start page.waitForNavigation() and the click together with Promise.all; this avoids missing a fast navigation. For a single-page app (SPA), wait for the destination content or state your script actually needs, since a client-side URL change may not produce a new document response.
This guide uses Puppeteer’s current Locator API for interaction. Locators wait for action conditions such as visibility, enabled state, viewport placement, and a stable bounding box. Puppeteer’s documentation recommends Locators for selecting and interacting with elements. Check your installed Puppeteer version if an API detail differs in your project.
1. Install Puppeteer and open a page
In a new project, install Puppeteer and save this example as navigate.js:
npm install puppeteer
const puppeteer = require('puppeteer');
async function main() {
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
const response = await page.goto('https://example.com');
console.log('HTTP status:', response?.status() ?? 'no document response');
console.log('Current URL:', page.url());
console.log('Title:', await page.title());
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with node navigate.js. Include the URL scheme, normally https://. page.goto() resolves with the main resource response, or null in documented cases where there is no new main-resource response.
2. Navigate directly when you know the destination
If the destination URL is known, direct navigation is usually the simplest and most reliable operation:
const response = await page.goto('https://example.com/account');
if (response) {
console.log('Status:', response.status());
}
console.log('Landed at:', page.url());
Use a direct URL when your goal is to inspect or capture a destination and the route to it does not matter. Click the actual link when the interaction itself matters, such as testing a menu, following a user journey, or verifying that a particular link leads to the expected page.
3. Click a link and wait for document navigation
Register the navigation wait before triggering the click. Await both promises concurrently:
const [response] = await Promise.all([
page.waitForNavigation(),
page.locator('a.my-link').click(),
]);
console.log('URL after click:', page.url());
console.log('HTTP status:', response?.status() ?? 'no document response');
The ordering matters. If you click first and only then call waitForNavigation(), a fast navigation can happen before the wait is registered. Puppeteer’s Page.click reference documents this race and shows the same concurrent-wait pattern.
Here is a complete example that starts at a page containing a link with the class my-link, clicks it, and checks the resulting page:
const puppeteer = require('puppeteer');
async function main() {
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto('https://example.com/start');
const [response] = await Promise.all([
page.waitForNavigation(),
page.locator('a.my-link').click(),
]);
console.log('Destination URL:', page.url());
console.log('Status:', response?.status() ?? 'no document response');
console.log('Title:', await page.title());
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Replace the start URL and selector with values from the site under automation. A generic selector such as a may match many links and is not a safe way to identify the intended destination.
4. Choose a selector that identifies the intended link
CSS selectors are the default and work well when the page has stable IDs, classes, or attributes. Puppeteer also supports text, accessibility role/name, XPath, and selectors that can cross open Shadow DOM boundaries. Prefer a selector based on stable page semantics rather than a long chain of incidental layout classes.
// CSS selector
await page.locator('a[href="/pricing"]').click();
// Accessibility role and accessible name
await page.locator('::-p-aria(link[name="Pricing"])').click();
// Text selector
await page.locator('::-p-text(Documentation)').click();
Use one selector style that is supported by your installed Puppeteer version. If the text or accessible name is duplicated, scope the locator to the relevant navigation region or use a more specific attribute. Avoid selecting the first match unless document order is part of the behavior you intend to test.
5. Wait for the destination state in a single-page app
An SPA may update the address bar with the History API and render new content without loading a new document. Anchor changes also count as navigation for waitForNavigation(), but either kind of URL change can resolve with null because no new main-resource response exists.
When the important outcome is application content, wait for a destination element as well as—or instead of—a document response. Choose a selector that is specific to the application and route:
await Promise.all([
page.waitForNavigation().catch(() => null),
page.locator('a.my-link').click(),
]);
// Use an application-specific element that appears in the destination view.
await page.locator('[data-page="account-settings"]').wait();
console.log('Destination state is ready:', page.url());
If the application handles the click entirely client-side, waiting only for a document response may time out. In that case, wait for a route-specific heading, panel, or other stable marker. There is no universal selector that can identify readiness on every site.
6. Locator waits versus waitForSelector
| Approach | Use it for | Behavior to account for |
|---|---|---|
page.locator(selector).click() |
Normal selection and interaction | Waits for action preconditions and retries when the target is not yet actionable. |
page.waitForSelector(selector) |
A lower-level wait for DOM presence, visibility, or hidden state | Returns an ElementHandle or, for the documented hidden case, null. It does not provide Locator-style automatic action retry. |
For example, use waitForSelector when a separate DOM wait is specifically needed:
const handle = await page.waitForSelector('.results', {
visible: true,
timeout: 10_000,
});
try {
console.log(await handle?.evaluate((element) => element.textContent));
} finally {
await handle?.dispose();
}
Dispose of ElementHandles when you no longer need them. For clicking, the Locator API is generally the more direct choice.
7. Troubleshoot common navigation problems
| Symptom | Likely cause | Fix |
|---|---|---|
waitForNavigation times out after the click |
The click did not trigger a document navigation, the selector matched the wrong target, or the site performs an SPA transition. | Confirm the clicked element and expected behavior. For client-side transitions, wait for the destination state or route-specific element. |
| The click seems to happen but the wait misses navigation | The navigation wait was registered after the click. | Start both operations in Promise.all, with waitForNavigation() listed before the click. |
The navigation response is null |
A History API URL change or anchor navigation occurred without a new main resource. | Check page.url() and wait for the application state you need; do not treat null alone as proof of failure. |
| Locator click times out or cannot act | The element is missing, disabled, obscured, outside the usable viewport, or not stable. | Verify the selector and page state, then allow the page to render. Locators wait for action preconditions; avoid bypassing them until you know why the target is not ready. |
| The wrong link is clicked | The selector matches multiple elements or relies on ambiguous text. | Refine it with a stable href, accessible role/name, or a locator scoped to the correct region. |
page.goto fails before the click step |
The URL is malformed or lacks a scheme, or the site could not be reached. | Use a complete URL such as https://example.com and surface the navigation error in logs before debugging the click. |
8. Reliability, speed, and cost considerations
- Wait for the condition you need. A navigation event tells you about navigation; it does not by itself establish that a particular SPA component or data request has finished. Add a destination-specific condition when the task depends on rendered content.
- Keep selectors stable. Prefer a unique route attribute, accessible name, or stable semantic selector. Repeated broad selectors are a common source of flaky interactions.
- Use direct navigation when the journey is irrelevant. It avoids opening intermediate pages and clicking through controls. Use clicks when validating the interaction path is part of the work.
- Manage browser lifetime. Close the browser in a
finallyblock so errors do not leave browser processes running. For repeated jobs, reuse a browser process where appropriate and create a fresh page or context for independent work. - Account for site behavior. Redirects, client-side rendering, and network delays affect completion time. Choose an explicit timeout that fits your job rather than allowing a stalled interaction to hold a worker indefinitely.
- Budget for browser execution. Self-hosted Puppeteer has no per-navigation ScreenshotNeo charge, but browser compute, memory, and operational time are still costs. A hosted screenshot API has its own plan and billing rules; review those before moving workloads.
9. Or skip the browser setup
If your goal is a screenshot of a URL rather than exercising a particular link, ScreenshotNeo can return an image or PDF from one GET request. See the ScreenshotNeo API documentation for the available options.
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; those cleanup steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server lets AI agents use screenshot, page-info, and PDF tools. The free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 screenshots. These options are for capturing a URL, not for testing that a specific link click works.
Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.
10. FAQ
Does Puppeteer click a link by its URL?
Use a selector that identifies the link, such as a CSS selector based on its href, then click its Locator. If you only need the destination page, navigate directly with page.goto().
Why can a URL change happen without a response?
History API and anchor navigation can change the URL without loading a new main document, so Puppeteer may resolve the navigation wait with null.
Should I use waitForSelector before every click?
Usually no. A Locator click waits for its action preconditions. Use waitForSelector when you need a separate lower-level DOM-state wait.
Can ScreenshotNeo verify that my link works?
No. ScreenshotNeo captures a supplied URL. Use Puppeteer when the link interaction or user journey itself must be exercised.


