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

STEP 1: create next app

npx create-next-app@latest capture-image-app

STEP 2: Install the necessary dependencies

npm install puppeteer puppeteer-core chrome-aws-lambda busboy

STEP 3: 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");
    }
}

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>
    );
}

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.