DEV Community

Ahmad Faraz
Ahmad Faraz

Posted on • Edited on

How to Capture Web Page Screenshots with Next.js and Puppeteer

Capturing screenshots of web pages programmatically can be incredibly useful for generating previews, creating image-based reports, and more. In this guide, we’ll build a Next.js API route that takes a URL and generates a PNG screenshot. Our setup uses Puppeteer and chrome-aws-lambda to leverage a headless Chrome browser, making it versatile and production-ready.

We’ll start by setting up a new Next.js project and walking through the code step-by-step to understand how the API captures screenshots.

Prerequisites

  • Setting up the Next.js app
  • Configuring the API route with Puppeteer
  • Creating the React component for the capture interface
  • Explanation of local vs. deployment configurations for Puppeteer

Getting Started with a New Next.js Project

  1. Create a new Next.js app:
npx create-next-app@latest capture-image-app
cd capture-image-app
Enter fullscreen mode Exit fullscreen mode
  1. Install the necessary dependencies:
npm install puppeteer puppeteer-core chrome-aws-lambda busboy
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the API Route to Generate Screenshots

Now, we’ll set up an API endpoint to capture and return screenshots based on a provided URL.

In the pages/api folder, create a new file named generate-png.ts and add this code:

import { NextApiRequest, NextApiResponse } from "next";
import busboy, { Busboy } from "busboy"; // Use busboy for multipart parsing
import chromium from "chrome-aws-lambda";
import puppeteerCore from "puppeteer-core"; // Import puppeteer-core directly
import puppeteer from "puppeteer"; // Import puppeteer directly

// Conditional import for Puppeteer based on the environment
const puppeteerModule = process.env.NODE_ENV === "production" ? puppeteerCore : puppeteer;

export const config = {
  api: {
    bodyParser: false, // Disable default body parsing to handle raw binary data (Blob)
  },
};

const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  try {
    if (req.method === "POST") {
      const bb: Busboy = busboy({ headers: req.headers });
      let width: number = 1920; // Default width
      let height: number = 0; // Default height
      let delayTime: number = 6000;
      const buffers: Buffer[] = [];

      bb.on("file", (_name: string, file: NodeJS.ReadableStream) => {
        file.on("data", (data: Buffer) => buffers.push(data));
      });

      bb.on("field", (name: string, value: string) => {
        if (name === "width") width = parseInt(value, 10) || 1920;
        if (name === "height") height = parseInt(value, 10) || 0;
        if (name === "delay") delayTime = parseInt(value, 10) || 6000;
      });

      bb.on("finish", async () => {
        const blobBuffer: Buffer = Buffer.concat(buffers);
        const htmlContent: string = blobBuffer.toString("utf-8");

        const browser = await puppeteerModule.launch({
          args: ["--start-maximized"],
          executablePath: process.env.NODE_ENV === "production"
            ? await chromium.executablePath || "/usr/bin/chromium-browser"
            : undefined,  // No custom executable path needed for local
          headless: true,
        });

        const page = await browser.newPage();

        // Load the HTML content directly
        await page.setContent(htmlContent, { waitUntil: "networkidle0" });

        //@ts-expect-error todo
        const bodyHeight = await page.evaluate(() => {
          return document.body.scrollHeight; // Get the full scrollable height of the body
        });

        await page.setViewport({
          width: Number(width),
          height: height || bodyHeight, // Use the provided height or fallback to the full body height
          deviceScaleFactor: 2,
        });

        await delay(delayTime);

        const screenshotBuffer = await page.screenshot({
          fullPage: !height,
          type: "png",
          omitBackground: false,
        });

        await browser.close();

        res.setHeader("Content-Type", "image/png");
        res.setHeader(
          "Content-Disposition",
          "attachment; filename=screenshot.png"
        );
        res.status(200).end(screenshotBuffer);
      });

      req.pipe(bb); // Pipe the request stream to busboy
    } else {
      res.setHeader("Allow", ["POST"]);
      res.status(405).end(`Method ${req.method} Not Allowed`);
    }
  } catch (error) {
    console.error("ERROR", error);
    res.status(500).end("Internal Server Error");
  }
}


Enter fullscreen mode Exit fullscreen mode

*Explanation: Choosing Puppeteer for Local vs. Production Environments
*

In this code, we’ve set up a dynamic import for puppeteer:

  • Local Development: If NODE_ENV is not production, it uses puppeteer, which is simpler to set up and doesn’t require chrome-aws-lambda.

  • Production: For serverless deployments, the environment will detect NODE_ENV as production and load puppeteer-core along with chrome-aws-lambda, which allows it to work in AWS Lambda and other similar environments. In this setup, chrome-aws-lambda provides the correct Chromium path, ensuring compatibility with serverless providers.

Step 3: Create a Simple React Component for the UI

Here, we’ll create a straightforward form that lets users input values for the webpage capture. This form will trigger the generate function to capture and download the screenshot in PDF format.

import { useState } from "react";

export default function ScreenCaptureComponent() {
  const [isProcessing, setProcessing] = useState(false);
  const [width, setWidth] = useState<string>("1920");
  const [height, setHeight] = useState<string>("1000");
  const [delay, setDelay] = useState<string>("6000");

  // Function to clone HTML and prepare for capture
  function takeScreenshot() {
    const clonedElement = document.body.cloneNode(true) as HTMLElement;
    const blob = new Blob([clonedElement.outerHTML], { type: "text/html" });
    return blob;
  }

  // Function to capture screenshot by sending cloned HTML to API
  async function generateCapture() {
    setProcessing(true);

    const htmlBlob = takeScreenshot();

    if (!htmlBlob) {
      setProcessing(false);
      return;
    }

    try {
      const formData = new FormData();
      formData.append("file", htmlBlob);
      formData.append("width", width);
      formData.append("height", height);
      formData.append("delay", delay);
      const response = await fetch("/api/generate-png", {
        method: "POST",
        body: formData,
      });

      if (!response.ok) throw new Error("Capture failed");

      const blob = await response.blob();
      const downloadUrl = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = downloadUrl;
      link.download = "capture.png";
      link.click();
      URL.revokeObjectURL(downloadUrl);
    } catch (error) {
      console.error("Failed to capture screenshot", error);
    } finally {
      setProcessing(false);
    }
  }

  return (
    <div
      style={{
        maxWidth: "400px",
        margin: "50px auto",
        padding: "24px",
        backgroundColor: "white",
        borderRadius: "8px",
        width: "100%",
        boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
      }}
    >
      <h2
        style={{
          fontSize: "24px",
          fontWeight: "600",
          textAlign: "center",
          marginBottom: "16px",
        }}
      >
        Webpage Screenshot Capture
      </h2>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          generateCapture();
        }}
        style={{
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          marginBottom: "16px",
        }}
      >
        <label
          style={{ marginBottom: "8px", fontWeight: "500" }}
          htmlFor="width"
        >
          Width (px)
        </label>
        <select
          id="width"
          value={width}
          onChange={(e) => setWidth(e.target.value)}
          style={{
            width: "100%",
            padding: "8px",
            marginBottom: "16px",
            borderRadius: "4px",
            border: "1px solid #ccc",
            outline: "none",
          }}
        >
          <option value="1920">1920 (Full HD)</option>
          <option value="1366">1366 (Laptop)</option>
          <option value="1280">1280 (Desktop)</option>
          <option value="1024">1024 (Tablet Landscape)</option>
          <option value="768">768 (Tablet Portrait)</option>
          <option value="375">375 (Mobile)</option>
        </select>

        <label
          style={{ marginBottom: "8px", fontWeight: "500" }}
          htmlFor="height"
        >
          Height (px)
        </label>
        <input
          type="number"
          id="height"
          value={height}
          onChange={(e) => setHeight(e.target.value)}
          required
          style={{
            width: "100%",
            padding: "8px",
            marginBottom: "16px",
            borderRadius: "4px",
            border: "1px solid #ccc",
            outline: "none",
          }}
        />

        <label
          style={{ marginBottom: "8px", fontWeight: "500" }}
          htmlFor="delay"
        >
          Delay (ms)
        </label>
        <input
          type="number"
          id="delay"
          value={delay}
          onChange={(e) => setDelay(e.target.value)}
          required
          style={{
            width: "100%",
            padding: "8px",
            marginBottom: "16px",
            borderRadius: "4px",
            border: "1px solid #ccc",
            outline: "none",
          }}
        />

        <button
          type="submit"
          disabled={isProcessing}
          style={{
            padding: "8px 16px",
            color: "white",
            borderRadius: "4px",
            transition: "background-color 0.3s",
            backgroundColor: isProcessing ? "#b0bec5" : "#2196F3",
            cursor: isProcessing ? "not-allowed" : "pointer",
          }}
        >
          {isProcessing ? "Capturing..." : "Capture Screenshot"}
        </button>
      </form>

      {/* Example HTML Element to Capture */}
      <div id="capture-area" style={{ display: "none" }}>
        <h3
          style={{
            fontSize: "20px",
            fontWeight: "600",
          }}
        >
          Content to Capture
        </h3>
        <p>This is an example of the HTML content that will be captured.</p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This tutorial covers setting up a webpage capture tool in Next.js, handling screenshots with Puppeteer, and creating an interactive frontend component. Remember to use puppeteer locally and switch to puppeteer-core in production to reduce bundle size and optimize for serverless environments. Happy coding!

Top comments (0)