ScreenshotNeo

BlogHow-to

How to Capture a Client-Side Screenshot and Save It on a PHP Server

Capture a DOM element or screen in JavaScript, upload it safely with fetch, and validate and store the image in PHP.

By the ScreenshotNeo team1 October 20267 min read

Short answer: render the target into a canvas, export it as a Blob, send that Blob with fetch() and FormData, then validate the uploaded bytes in PHP before moving the file to storage. Use html2canvas for a DOM element or page region. Use navigator.mediaDevices.getDisplayMedia() when the user must choose a real screen, window or tab.

This distinction matters: html2canvas reconstructs DOM information and does not capture browser chrome or guarantee a pixel-perfect display capture. getDisplayMedia() captures a permissioned display stream, requires HTTPS and has more limited browser availability.

1. Choose the capture type

Requirement Browser API Permission Typical result
Capture an element or page region html2canvas None beyond the page Canvas reconstructed from DOM and styles
Capture the whole DOM page html2canvas on document.body None Long page image, subject to rendering limits
Capture a screen, window or tab getDisplayMedia() User must select and approve a surface Video frame drawn into a canvas

For either approach, use canvas.toBlob() for uploads. toDataURL() creates a base64 data URL in memory; MDN recommends toBlob() for larger images.

2. Create the PHP upload endpoint first

Save this as save-screenshot.php. Create an uploads directory with permissions that allow the PHP process to write. Prefer a directory outside the public web root; if it must be public, disable script execution there.

<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'POST required']);
    exit;
}

if (!isset($_FILES['shot']) || is_array($_FILES['shot']['error'])) {
    http_response_code(400);
    echo json_encode(['error' => 'Missing or malformed shot upload']);
    exit;
}

$file = $_FILES['shot'];
$maxBytes = 5_000_000;

if ($file['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    echo json_encode(['error' => 'Upload failed', 'code' => $file['error']]);
    exit;
}

if (!is_uploaded_file($file['tmp_name']) || $file['size'] < 1 || $file['size'] > $maxBytes) {
    http_response_code(413);
    echo json_encode(['error' => 'Invalid or oversized upload']);
    exit;
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$extensions = [
    'image/png' => 'png',
    'image/jpeg' => 'jpg',
    'image/webp' => 'webp',
];
$extension = $extensions[$mime] ?? null;

if ($extension === null) {
    http_response_code(415);
    echo json_encode(['error' => 'Only PNG, JPEG and WebP images are accepted']);
    exit;
}

$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true)) {
    http_response_code(500);
    echo json_encode(['error' => 'Storage directory unavailable']);
    exit;
}

$name = bin2hex(random_bytes(16)) . '.' . $extension;
$destination = $uploadDir . DIRECTORY_SEPARATOR . $name;

if (!move_uploaded_file($file['tmp_name'], $destination)) {
    http_response_code(500);
    echo json_encode(['error' => 'Could not persist upload']);
    exit;
}

http_response_code(201);
echo json_encode(['file' => $name]);

finfo inspects the temporary file rather than trusting the browser-supplied MIME field. move_uploaded_file() accepts files uploaded through PHP’s HTTP POST mechanism and can overwrite an existing destination, so the server-generated random name is intentional.

3. Capture a DOM element with html2canvas

Install or load html2canvas from its official distribution, then give the page an element to capture:

<button id="save">Save screenshot</button>
<section id="capture" style="padding:2rem;background:#fff;color:#111">
  <h1>Report</h1>
  <p>This region will be captured.</p>
</section>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
<script>
async function uploadCanvas(canvas) {
  const blob = await new Promise((resolve, reject) =>
    canvas.toBlob(value => value ? resolve(value) : reject(new Error('Canvas export failed')), 'image/png')
  );
  const form = new FormData();
  form.append('shot', blob, 'screenshot.png');

  const response = await fetch('/save-screenshot.php', {
    method: 'POST',
    body: form,
    credentials: 'same-origin'
  });
  const result = await response.json();
  if (!response.ok) throw new Error(result.error || 'Upload failed');
  return result;
}

document.querySelector('#save').addEventListener('click', async () => {
  try {
    const canvas = await html2canvas(document.querySelector('#capture'), {
      scale: window.devicePixelRatio,
      useCORS: true,
      backgroundColor: '#ffffff'
    });
    const result = await uploadCanvas(canvas);
    console.log('Saved:', result.file);
  } catch (error) {
    console.error(error);
  }
});
</script>

scale: window.devicePixelRatio produces sharper output on high-DPI screens. useCORS: true helps only when the remote image server sends an appropriate CORS header.

4. Capture the whole DOM page

const canvas = await html2canvas(document.body, {
  scale: Math.min(window.devicePixelRatio, 2),
  useCORS: true,
  backgroundColor: '#fff',
  windowWidth: document.documentElement.scrollWidth,
  windowHeight: document.documentElement.scrollHeight
});
await uploadCanvas(canvas);

Very tall or wide pages can exceed browser canvas dimensions or memory. Capture sections separately, reduce the scale, or use a server-side screenshot service for reliable long-page rendering.

5. Capture a real screen, window or tab

getDisplayMedia() must be called from a user action such as a button click and from a secure context (HTTPS, or localhost during development).

<button id="screen">Choose screen or tab</button>
<video id="preview" autoplay muted playsinline style="max-width:100%"></video>
<script>
async function captureDisplay() {
  if (!navigator.mediaDevices?.getDisplayMedia) {
    throw new Error('Display capture is not supported in this browser');
  }
  const stream = await navigator.mediaDevices.getDisplayMedia({
    video: { cursor: 'always' },
    audio: false
  });
  const video = document.querySelector('#preview');
  video.srcObject = stream;
  await new Promise(resolve => video.addEventListener('loadedmetadata', resolve, { once: true }));
  await video.play();

  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  canvas.getContext('2d').drawImage(video, 0, 0);
  const result = await uploadCanvas(canvas);
  stream.getTracks().forEach(track => track.stop());
  video.srcObject = null;
  return result;
}

document.querySelector('#screen').addEventListener('click', async () => {
  try { console.log(await captureDisplay()); }
  catch (error) { console.error(error); }
});
</script>

The user can cancel the picker, stop sharing, or select a surface your browser does not support. Handle those outcomes as normal errors and offer the DOM capture fallback where appropriate.

6. Export formats and upload choices

  • canvas.toBlob(callback, 'image/png') preserves lossless detail and transparency.
  • Use image/jpeg with a quality such as 0.85 for photographic screenshots; JPEG has no transparency.
  • Use image/webp when your browser and downstream tooling support it.
  • Do not manually set the Content-Type header for FormData; the browser adds the multipart boundary.
  • Use toDataURL() only when an inline data URL is specifically needed. Its base64 string increases memory use.

7. Cross-origin images and tainted canvases

A canvas becomes tainted when it includes pixels from another origin that did not opt in through CORS. Reading it with toBlob() or toDataURL() then fails. Configure the image host to send Access-Control-Allow-Origin, set the image’s crossOrigin attribute before assigning src, and use html2canvas’s useCORS option. If you cannot change the remote server, proxy the asset through your own origin with appropriate access controls or omit it.

8. Security checklist for PHP

  • Limit request size in application code and in PHP/web-server configuration.
  • Inspect MIME bytes with finfo; never trust the original filename or browser MIME field.
  • Generate the filename on the server and prevent path traversal.
  • Store uploads outside executable directories and apply restrictive permissions.
  • Require authentication or CSRF protection when screenshots are private or tied to an account.
  • Rate-limit the endpoint and remove abandoned temporary files.
  • Return JSON errors with suitable HTTP status codes and avoid exposing filesystem paths.

9. Troubleshooting

Symptom Cause Fix
Blank or missing images Remote assets are blocked or not loaded yet Wait for assets, enable CORS, or capture after the page is ready.
SecurityError on export Tainted canvas from cross-origin pixels Configure CORS or remove/proxy the offending resource.
Upload says “missing shot” Field name differs Use form.append('shot', blob, 'screenshot.png') and read $_FILES['shot'].
HTTP 413 Payload exceeds your limit Reduce scale or quality, increase server limits deliberately, and keep an application limit.
move_uploaded_file() fails Directory is missing or not writable Create the directory and grant write access to the PHP process.
Screen picker is unavailable Insecure context, unsupported browser, or no user activation Use HTTPS, call from a click, and provide an html2canvas fallback.
Image is blurry Canvas scale is too low Use device-pixel scaling, while watching memory and maximum canvas dimensions.

10. Performance, reliability and cost

Large canvases consume memory proportional to pixel count. Cap device-pixel scaling, avoid capturing hidden full-page content unnecessarily, and compress before upload when PNG files are too large. A Blob upload avoids the extra base64 expansion of a data URL. For repeated captures, debounce the capture button and upload in the background.

Client capture depends on the user’s browser, permissions, fonts, network assets and device memory. Server validation should remain strict even when the client is your own page. If you need consistent rendering of arbitrary public URLs, a browser running on your server or a screenshot API removes the user’s browser and permission step.

Or skip the browser setup

ScreenshotNeo captures a URL with one request and returns PNG, JPEG, WebP or PDF. Cookie banners, newsletter popups and chat widgets are removed before the shot; bot checks, blank pages and failed loads are never billed. Its response identifies the page verdict and billing result with X-Page-Verdict and X-Billed headers. An MCP server lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf.

See the ScreenshotNeo API documentation for all 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}`);

The free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.

FAQ

Can JavaScript capture the browser address bar?

No. DOM capture sees page content. Display capture can include a selected browser window or tab only when the user grants it.

Should I send a PNG or JPEG?

Use PNG for text, diagrams and transparency. Use JPEG or WebP when smaller files matter and transparency is unnecessary.

Can PHP trust the uploaded filename?

No. Treat it as untrusted metadata. Inspect the temporary file, choose an extension from the detected MIME type and generate a new name.

Why does html2canvas differ from what I see?

It reconstructs the DOM rather than taking a compositor-level screenshot, so unsupported CSS, cross-origin resources and timing can change the result.