ScreenshotNeo

BlogHow-to

How to Add Custom Scripts to a Page in Puppeteer

Learn when to use addScriptTag, evaluate, and evaluateOnNewDocument in Puppeteer, with iframe examples, timing guidance, troubleshooting, and production patterns.

By the ScreenshotNeo team29 September 202610 min read

How to Add Custom Scripts to a Page in Puppeteer

Short answer: use page.addScriptTag() when you need to insert a real <script> element, page.evaluate() for a one-off function in the page context, and page.evaluateOnNewDocument() when setup must run after a document is created but before the site’s own scripts. For an iframe, find the target Frame and call the same method on that frame.

Those APIs solve different timing and execution problems. Picking the wrong one can make a script run too late, run in the wrong frame, or fail because browser code is being mixed with Node.js code. This guide shows each approach with runnable examples, explains every relevant option, and covers reliability, debugging, and production use.

Choose the right Puppeteer API

Goal Use When it runs What it creates
Load a local, inline, or remote script page.addScriptTag() When you call it in the current document A script element and an element handle
Read or change the page once page.evaluate() Immediately in the current execution context No script element
Patch globals before application code page.evaluateOnNewDocument() After document creation, before page scripts A registered startup function
Target an iframe The corresponding Frame method Inside that frame’s context Frame-local execution or script element

Puppeteer’s Page.addScriptTag API is a shortcut for the main frame’s method. The related Page.evaluate API runs a serialized function in the page, while Page.evaluateOnNewDocument is designed for code that must be installed before the page’s scripts execute.

The choice between addScriptTag, evaluate, and a final capture depends on where and when your code runs.
The choice between addScriptTag, evaluate, and a final capture depends on where and when your code runs.

1. Inject a local JavaScript file with addScriptTag

Use path when your script is stored on the machine running Puppeteer. The path is resolved from Node.js process.cwd(), so a relative path depends on the directory from which you start the process.

import puppeteer from 'puppeteer';

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

  const scriptElement = await page.addScriptTag({
    path: './custom.js',
  });

  console.log('Injected source:', await scriptElement.evaluate(el => el.src));
  console.log('Page value:', await page.evaluate(() => window.myCustomValue));
} finally {
  await browser.close();
}

A minimal custom.js could be:

window.myCustomValue = 'injected';
document.documentElement.dataset.automation = 'ready';

addScriptTag() returns an element handle for the inserted script element. Await the call before depending on side effects. If the file cannot be read or loaded, the promise rejects.

Path resolution and packaging

  • Confirm the file exists relative to process.cwd(), not necessarily relative to the JavaScript file that contains your code.
  • In a container or CI job, copy the script into the image and use an absolute path when the working directory is uncertain.
  • Keep injected files self-contained. A browser page cannot import a Node-only package such as fs or access variables from the surrounding Node module.

2. Inject inline JavaScript

Use content when the script is generated at runtime or is short enough to keep in your automation code.

await page.addScriptTag({
  content: `
    window.featureFlags = { screenshots: true };
    document.body.classList.add('automation-mode');
  `,
});

const flags = await page.evaluate(() => window.featureFlags);
console.log(flags);

Inline code is still executed by the browser. It has access to DOM and Web APIs, but not to Node.js variables unless you pass values into the page or interpolate them into the string carefully. For values supplied by users, prefer page.evaluate() arguments over string construction so quotes and special characters cannot corrupt the script.

3. Load a remote script with url

The url option asks the browser to load a script from a URL in the current page context.

await page.addScriptTag({
  url: 'https://example.com/custom.js',
});

The remote server must be reachable from the browser, and the page environment may restrict the load through Content Security Policy or other site controls. A URL accepted by Puppeteer is not a guarantee that the remote host is available or that the target site permits the script.

Options: id and type

You can set an element id and the script type. Set type: 'module' for an ES module.

await page.addScriptTag({
  content: `export const answer = 42;`,
  id: 'automation-module',
  type: 'module',
});

Module loading is asynchronous and follows browser module rules. If later code depends on an exported value, coordinate that dependency explicitly, for example by having the module set a global after initialization or by waiting for a page-side readiness flag.

4. Run a one-off function with page.evaluate

If you do not need a script element, evaluate() is usually the simplest choice. Puppeteer serializes the function and runs it in the page’s JavaScript context.

const title = await page.evaluate(() => document.title);
console.log(title);

const label = 'Automation test';
await page.evaluate(text => {
  document.body.dataset.testLabel = text;
}, label);

The function cannot see lexical variables or helper functions defined only in Node.js. Pass every required value as an argument:

const selector = '[data-price]';
const prices = await page.evaluate(sel => {
  return [...document.querySelectorAll(sel)].map(node => node.textContent?.trim());
}, selector);

Puppeteer waits for a promise returned by the evaluated function. Returned primitives and ordinary objects are serialized. For a live in-page object, use page.evaluateHandle() instead.

Wait for page-side asynchronous work

await page.evaluate(async () => {
  await new Promise(resolve => setTimeout(resolve, 250));
  window.readyForCapture = true;
});

await page.waitForFunction(() => window.readyForCapture === true);

Keep browser-side work bounded. An unresolved promise in evaluate() leaves the Puppeteer operation pending until your surrounding timeout or process termination.

5. Run setup before the page’s own scripts

Register evaluateOnNewDocument() before navigation when the site must observe your changes during startup. The function runs after the document is created but before that document’s scripts. It also applies when child frames are attached or navigated.

An iframe has its own execution context, so inject through the matching Puppeteer Frame.
An iframe has its own execution context, so inject through the matching Puppeteer Frame.
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();

  const scriptId = await page.evaluateOnNewDocument(() => {
    Object.defineProperty(navigator, 'languages', {
      get: () => ['en-US', 'en'],
    });
    window.startupMarker = 'installed-before-page-code';
  });

  await page.goto('https://example.com', {waitUntil: 'domcontentloaded'});
  console.log(await page.evaluate(() => window.startupMarker));

  await page.removeScriptToEvaluateOnNewDocument(scriptId);
} finally {
  await browser.close();
}

Use this API for instrumentation, deterministic startup values, or controlled browser-environment shims. It is not a replacement for addScriptTag() when you specifically need a visible script element in the document.

6. Inject into an iframe

page.addScriptTag() targets the main frame. An iframe has its own document and JavaScript context, so locate its Frame and call the frame method.

const frame = page.frames().find(frame => frame.url().includes('/widget'));
if (!frame) {
  throw new Error('Widget frame not found');
}

await frame.addScriptTag({
  content: `window.widgetReady = true;`,
});

const ready = await frame.evaluate(() => window.widgetReady);
console.log('Widget ready:', ready);

Frame URLs and structure are site-specific. For a stable target, prefer a known frame URL, name, or a frame discovered from the matching iframe element. A cross-origin iframe still has a separate execution context; Puppeteer can operate through its Frame object, but page code cannot directly read the frame’s DOM.

Wait for a dynamically created frame

await page.waitForSelector('iframe.widget');

const frame = await (async () => {
  for (const candidate of page.frames()) {
    if (candidate.url().includes('/widget')) return candidate;
  }
  return null;
})();

if (!frame) throw new Error('The widget frame did not finish loading');
await frame.evaluate(() => { window.widgetReady = true; });

7. A complete pattern with timing, errors, and cleanup

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({headless: true});
try {
  const page = await browser.newPage();
  page.setDefaultTimeout(15_000);

  await page.evaluateOnNewDocument(() => {
    window.captureStarted = Date.now();
  });

  await page.goto('https://example.com', {
    waitUntil: 'networkidle2',
    timeout: 30_000,
  });

  await page.addScriptTag({path: './custom.js'});
  await page.waitForFunction(() => window.customScriptReady === true);

  const result = await page.evaluate(() => ({
    title: document.title,
    started: window.captureStarted,
    ready: window.customScriptReady,
  }));
  console.log(result);
} catch (error) {
  console.error('Browser operation failed:', error);
  throw error;
} finally {
  await browser.close();
}

8. Troubleshooting common failures

Symptom Likely cause Fix
“Cannot find module” or missing file The path is relative to the wrong working directory. Log process.cwd(), verify the file exists, or pass an absolute path.
Code runs but sees no Node variable evaluate() executes in the browser context. Pass values as arguments; do not rely on closures.
Site code ran before your patch addScriptTag() was called after navigation. Register evaluateOnNewDocument() before goto().
Script appears in the wrong document The target is an iframe, not the main frame. Find the desired Frame and call frame.addScriptTag() or frame.evaluate().
Remote script never loads Network failure, CSP, certificate issue, or unavailable host. Check browser console and request events, test the URL from the runtime, and use a local or inline script when appropriate.
Evaluation hangs The evaluated function returned a promise that never settles. Ensure every asynchronous branch resolves and set explicit navigation and operation timeouts.
Module has syntax or import errors The page treats the code as classic JavaScript or an import is unavailable. Set type: 'module', use browser-compatible imports, and wait for a readiness signal.
Changes disappear after navigation Each navigation creates a new document. Use evaluateOnNewDocument() for repeatable startup setup, or inject again after each navigation.

9. Reliability and performance guidance

  • Install early only when needed. Startup hooks run for every new document and child frame, so keep them small and deterministic.
  • Prefer one evaluation for related reads. Returning one object from a single evaluate() call reduces round trips between Node.js and the browser.
  • Wait for a condition, not an arbitrary sleep. waitForFunction(), a selector wait, or a page-side readiness flag is usually more reliable than a fixed delay.
  • Use explicit timeouts. Set navigation and default operation timeouts so a dead resource cannot hold a worker forever.
  • Clean up browser instances. Put browser.close() in finally and remove temporary startup registrations when a page is reused.
  • Watch memory. Large script strings, many pages, and repeated browser contexts increase memory use. Reuse a controlled browser process and close pages that are no longer needed.
  • Capture diagnostics. Log the URL, frame URL, script source type, and error stack. Listen for page console and request-failed events when remote loading is involved.

10. Or skip the browser setup

If your goal is a screenshot rather than browser automation itself, ScreenshotNeo provides a single GET request that returns a PNG, JPEG, WebP, or PDF. Its clean-shot pipeline accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Each step can be turned off.

Only clean shots are billed. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and each response identifies the result with X-Page-Verdict and X-Billed headers. ScreenshotNeo also provides an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options. A basic call is:

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,
)
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}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());

ScreenshotNeo supports full-page capture with lazy images loaded, CSS-selector element capture, dark mode, 12 device presets or any viewport, retina scale, PDF paper sizes and page ranges, custom CSS and JavaScript, clicks, selector or network-idle waits, request and resource blocking, custom headers and cookies, user agents, Authorization, timezone and geolocation, transparent backgrounds, resizing, configurable caching TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture for 100 URLs per call, a usage API, and an OpenAPI specification. Existing parameter names used by other screenshot APIs also work.

Plans include 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000 shots. Yearly billing gives two months free. Create a free ScreenshotNeo account and start with the free allowance.

11. Puppeteer versus ScreenshotNeo for screenshot workflows

Puppeteer gives you complete browser control: inject arbitrary code, inspect frames, interact with forms, and build a custom sequence. ScreenshotNeo is the shorter path when the deliverable is a reliable page image or PDF and you do not want to maintain Chromium launch settings, waits, consent handling, popup removal, or capture plumbing. You can also combine them: use Puppeteer for bespoke interaction and ScreenshotNeo for straightforward URL capture at scale.

FAQ

How do I inject JavaScript into a Puppeteer page?

Call page.addScriptTag() with path, content, or url. Use page.evaluate() when you only need to run a function and do not need a script element.

How do I run JavaScript before the page loads?

Register page.evaluateOnNewDocument() before page.goto(). It runs after document creation and before that document’s own scripts.

How do I add a script to an iframe?

Find the desired frame in page.frames(), then call frame.addScriptTag() or frame.evaluate(). The page-level method targets the main frame.

Does addScriptTag wait for the script to finish?

Await the returned promise to wait for insertion and loading. For application initialization, expose a page-side readiness flag and wait for that condition explicitly.

Why did my injected code vanish?

Navigation replaces the document. Inject again after navigation or register the code with evaluateOnNewDocument() when it must run on every new document.