Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use Puppeteer’s page.$$eval('a', ...) to remove every ordinary anchor from the current document in one page-context operation. If you need to keep the visible text, change or remove each anchor’s href instead of removing the element. The choice matters because anchor.remove() deletes the anchor and everything inside it, while editing href preserves the content and layout.

Remove every link and its contents

This is the direct solution for a page where links, including their text and child elements, should disappear:

const removedCount = await page.$$eval('a', anchors => {
  const count = anchors.length;
  anchors.forEach(anchor => anchor.remove());
  return count;
});

console.log(`Removed ${removedCount} links`);

page.$$eval() finds all elements matching the CSS selector, passes the resulting array to one callback in the page context, and returns the callback’s result to Node.js. The returned count is useful for logging, assertions, or deciding whether a page had the content you expected.

The selector 'a' matches ordinary anchor elements regardless of whether they have an href attribute. That means this also removes anchors being used as buttons or placeholders.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A complete runnable example

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({headless: true});
  try {
    const page = await browser.newPage();
    await page.goto('https://example.com', {waitUntil: 'networkidle2'});

    const removedCount = await page.$$eval('a', anchors => {
      const count = anchors.length;
      anchors.forEach(anchor => anchor.remove());
      return count;
    });

    console.log(`Removed ${removedCount} links`);
    await page.screenshot({path: 'without-links.png', fullPage: true});
  } finally {
    await browser.close();
  }
})();

Replace the URL with the page you control or are authorized to process. The mutation affects the rendered page held by this browser tab; it does not edit the site’s source files or server response. A reload, a new navigation, or a fresh browser session starts with the original markup again.

Keep the linked text but disable navigation

Removing an anchor removes its child text and markup. To make links non-clickable while retaining what readers see, leave the element in place and remove or neutralize its destination:

const disabledCount = await page.$$eval('a', anchors => {
  let count = 0;
  for (const anchor of anchors) {
    if (anchor.hasAttribute('href')) {
      anchor.removeAttribute('href');
      count++;
    }
  }
  return count;
});

console.log(`Disabled ${disabledCount} links`);

This preserves the anchor’s text, descendants, classes, and most styling. Removing href changes browser semantics, so a page that needs keyboard-accessible, visibly non-interactive text may also need a class or ARIA treatment appropriate to its application. Do not add an invented replacement destination merely to stop navigation.

Preserve the text but remove the anchor wrapper

If the document must contain plain text rather than an anchor, replace each anchor with its children. This keeps nested markup such as <strong> or icons, unlike assigning only textContent:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const unwrappedCount = await page.$$eval('a', anchors => {
  for (const anchor of anchors) {
    anchor.replaceWith(...anchor.childNodes);
  }
  return anchors.length;
});

console.log(`Unwrapped ${unwrappedCount} links`);

Use this variant when the content should remain in the layout but the DOM should no longer expose an anchor element. An empty anchor contributes no visible content and simply disappears when replaced with no child nodes.

Choose the Puppeteer API for the job

page.$$eval() for all matches

Use page.$$eval(selector, pageFunction) for a bulk edit. Puppeteer gathers every matching element and invokes one callback in the page context. It is concise, avoids a round trip for each anchor, and is the normal choice for removing or rewriting all links.

page.$eval() for the first match

page.$eval('a', anchor => anchor.remove()) acts only on the first matching anchor. It throws when no element matches, so guard optional content:

const firstLink = await page.$('a');
if (firstLink) {
  await page.$eval('a', anchor => anchor.remove());
}

Do not use a loop that repeatedly calls $eval when the goal is to edit every match; a single $$eval callback is simpler and less chatty.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

page.evaluate() for custom document logic

Use page.evaluate() when selection depends on conditions, multiple selectors, or a larger DOM procedure:

const result = await page.evaluate(() => {
  const anchors = [...document.querySelectorAll('a')];
  const external = anchors.filter(anchor => {
    try {
      return new URL(anchor.href, document.baseURI).origin !== location.origin;
    } catch {
      return false;
    }
  });

  external.forEach(anchor => anchor.remove());
  return {found: anchors.length, removed: external.length};
});

console.log(result);

The function runs in the page context and its serializable return value comes back to Node.js. Passing a function, rather than a string expression, keeps the operation easier to validate and maintain. The example removes only links whose resolved origin differs from the current page; adjust the predicate for your policy.

Target only the links you intend to change

A broad 'a' selector includes navigation, in-article references, mail links, telephone links, anchors without href, and links inside areas you may want to keep. Narrow the selector or test attributes inside the callback:

// Remove links in a particular region
await page.$$eval('main article a', anchors => {
  anchors.forEach(anchor => anchor.remove());
});

// Remove only links with a real HTTP(S) destination
await page.$$eval('a[href]', anchors => {
  anchors
    .filter(anchor => /^(https?:)?///i.test(anchor.getAttribute('href') || ''))
    .forEach(anchor => anchor.remove());
});

// Remove links carrying a marker class
await page.$$eval('a.strip-link', anchors => {
  anchors.forEach(anchor => anchor.remove());
});

Use getAttribute('href') when you need the author-supplied value. The anchor.href property is an absolute, resolved URL and can therefore differ from the literal HTML.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Timing: remove links after the page has rendered

Evaluation runs when you call it. If a framework inserts anchors later, those new nodes are unaffected. Wait for a meaningful selector, a known application state, or a network-idle milestone before mutating:

await page.goto('https://example.com', {waitUntil: 'domcontentloaded'});
await page.waitForSelector('main');
await page.$$eval('a', anchors => anchors.forEach(anchor => anchor.remove()));

For content that continually changes, run the operation again after the application’s update, or install a deliberate observer in the page:

await page.evaluate(() => {
  const removeAnchors = (root) => {
    if (root.nodeType === Node.ELEMENT_NODE && root.matches('a')) {
      root.remove();
      return;
    }
    root.querySelectorAll?.('a').forEach(anchor => anchor.remove());
  };

  removeAnchors(document);
  const observer = new MutationObserver(records => {
    for (const record of records) {
      record.addedNodes.forEach(removeAnchors);
    }
  });
  observer.observe(document.documentElement, {childList: true, subtree: true});
});

An observer persists for the lifetime of that document and can have a cost on pages with heavy mutation. Disconnect it when the capture or task is complete by retaining the observer in page state or by using a bounded, one-time pass instead.

Frames and shadow roots

Links inside frames

Each frame has a separate document. A page-level query does not reach into an iframe. Iterate the frames and evaluate in the relevant frame:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
for (const frame of page.frames()) {
  await frame.$$eval('a', anchors => {
    anchors.forEach(anchor => anchor.remove());
  });
}

Cross-origin frame restrictions still apply to what the browser context can access. If a frame navigates after your pass, perform the mutation again after its final content is available.

Links in shadow DOM

A normal document-level querySelectorAll('a') does not automatically traverse shadow roots. Open shadow roots require explicit traversal or Puppeteer’s supported deep-selector mechanisms. Closed shadow roots cannot be queried through ordinary page JavaScript. If shadow content matters, identify the component owners and traverse each accessible root deliberately rather than assuming the top-level selector covers it.

Common failures and fixes

“Nothing was removed”

  • The selector matched zero nodes: return and log the count, then inspect the actual markup and wait for the component to render.
  • The links are in an iframe or shadow root: evaluate in the frame or traverse an open shadow root.
  • The page replaced your mutation: run after the final render event or use a bounded observer.

“Cannot read properties of undefined” or a missing-element error

$eval expects a match. Use page.$() to test first, or use $$eval, which safely receives an empty array when there are no matches.

The visible text vanished

That is the expected result of anchor.remove(); the anchor’s children are removed with it. Use removeAttribute('href') or the unwrapping pattern when text must remain.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The screenshot still shows links

Take the screenshot after the awaited evaluation completes. Also check that you edited the correct frame and that client-side code did not insert fresh anchors between evaluation and capture.

Navigation or reload restores every link

The operation changes only the current rendered DOM. Apply it after every navigation, or change the page source/server rendering if persistence is the actual requirement.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

When your end goal is a clean image or PDF rather than DOM inspection, ScreenshotNeo can capture the URL through one request. It accepts consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be disabled. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the complete option list and authentication details in the ScreenshotNeo documentation. The same endpoint supports PNG, JPEG, WebP, or PDF, full-page lazy-image loading, CSS-selector element capture, dark mode, device presets and custom viewports, retina scale, PDF paper and margin controls, custom CSS and JavaScript, clicks, selector or network-idle waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage data, and an OpenAPI specification.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
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 also provides an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

Performance, reliability, and safety notes

  • Batch in one callback: $$eval avoids issuing one Node-to-page call per anchor.
  • Keep the mutation small: removing thousands of nodes can trigger substantial layout and style recalculation; limit the selector when possible.
  • Wait for the right state: network idle is useful, but application-specific selectors are often a stronger signal than a quiet network.
  • Capture after mutation: await the evaluation and, if visual stability matters, allow the page’s layout to settle before taking a screenshot or PDF.
  • Respect authorization: DOM removal in an automated browser should be used on pages you are permitted to access and process.

Frequently Asked Questions

Does Puppeteer remove links from the website permanently?

No. Evaluation changes only the current document in the browser tab. Reloading or navigating restores the original page unless you also change the application’s source or server output.

Can I remove only external links?

Yes. In a page-context callback, resolve each anchor with new URL(anchor.href, document.baseURI) and remove only those whose origin differs from location.origin.

Will page.$$eval('a') find links inside an iframe?

No. Frames have separate documents; run the query against the relevant Frame object.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.