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 notproduction
, it usespuppeteer
, which is simpler to set up and doesn’t requirechrome-aws-lambda
. -
Production: For serverless deployments, the environment will detect
NODE_ENV
asproduction
and loadpuppeteer-core
along withchrome-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.