How to Capture a Full-Page Screenshot of a React or Next.js App
Screenshotting a React app with Puppeteer has a timing problem: you navigate to the URL, but the page is still hydrating, fetching data, and rendering. Most Puppeteer scripts add a sleep(2000) and hope for the best. Sometimes it works.
Here's a more reliable approach: render the page, wait for a stable selector, screenshot it — all from a Next.js API route or a Node.js script, without running a browser.
Next.js App Router — screenshot API route
// app/api/screenshot/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { url, selector, fullPage = true } = await req.json();
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
fullPage,
blockBanners: true,
blockAds: true,
// Wait for a specific element before screenshotting
...(selector && { waitFor: selector }),
}),
});
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: res.status });
}
const image = await res.arrayBuffer();
return new NextResponse(image, {
headers: { "Content-Type": "image/png" },
});
}
Call from your frontend:
const res = await fetch("/api/screenshot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://yourapp.com/dashboard",
fullPage: true,
}),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
// display or download
Pages Router API route
// pages/api/screenshot.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { url, fullPage = true } = req.body;
const screenshotRes = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ url, fullPage, blockBanners: true }),
});
const image = await screenshotRes.arrayBuffer();
res.setHeader("Content-Type", "image/png");
res.send(Buffer.from(image));
}
Screenshot a page that requires auth (session cookie)
// app/api/screenshot/dashboard/route.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return new NextResponse("Unauthorized", { status: 401 });
// Generate a short-lived signed URL for the page
const token = await createSignedToken(session.user.id, { expiresIn: 60 });
const url = `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?token=${token}`;
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ url, fullPage: true, blockBanners: true }),
});
const image = await res.arrayBuffer();
return new NextResponse(image, {
headers: { "Content-Type": "image/png" },
});
}
Screenshot from a Node.js script (no Next.js)
// scripts/screenshot.ts
import fs from "fs/promises";
async function screenshotPage(url: string, outputPath: string) {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
fullPage: true,
blockBanners: true,
blockAds: true,
blockTrackers: true,
}),
});
if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
const image = Buffer.from(await res.arrayBuffer());
await fs.writeFile(outputPath, image);
console.log(`Saved: ${outputPath} (${image.length} bytes)`);
}
// Screenshot several routes
const routes = ["/", "/pricing", "/docs", "/blog"];
for (const route of routes) {
const slug = route.replace(/\//g, "-").replace(/^-/, "") || "home";
await screenshotPage(`https://yourapp.com${route}`, `screenshots/${slug}.png`);
}
Handle SPAs that load data async
For pages where content loads after the initial render, the waitFor parameter waits for a CSS selector to appear before capturing:
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: { "x-api-key": process.env.PAGEBOLT_API_KEY!, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://yourapp.com/dashboard",
fullPage: true,
// Wait until the dashboard data table is rendered
waitFor: "[data-testid='metrics-loaded']",
blockBanners: true,
}),
});
Add data-testid="metrics-loaded" (or any stable selector) to the element that appears when async data finishes loading. The screenshot waits for it.
CI — screenshot all routes on every deploy
# .github/workflows/screenshots.yml
name: Route screenshots
on:
push:
branches: [main]
jobs:
screenshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Wait for deployment
run: |
for i in $(seq 1 30); do
[ "$(curl -s -o /dev/null -w '%{http_code}' '${{ vars.PRODUCTION_URL }}')" = "200" ] && break
sleep 10
done
- name: Screenshot all routes
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
BASE_URL: ${{ vars.PRODUCTION_URL }}
run: |
node -e "
const routes = ['/', '/pricing', '/docs', '/blog', '/login'];
const base = process.env.BASE_URL;
Promise.all(routes.map(async r => {
const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {'x-api-key': process.env.PAGEBOLT_API_KEY, 'Content-Type': 'application/json'},
body: JSON.stringify({url: base + r, fullPage: true, blockBanners: true})
});
const buf = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('screenshot-' + r.replace(/\//g,'-').replace(/^-/,'home') + '.png', buf);
console.log('✓', r);
}));
"
- uses: actions/upload-artifact@v4
with:
name: deploy-screenshots
path: "*.png"
PDF from a rendered React page
Same pattern, swap the endpoint:
const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: `${process.env.NEXT_PUBLIC_APP_URL}/invoices/${invoiceId}?token=${token}`,
blockBanners: true,
}),
});
const pdf = Buffer.from(await pdfRes.arrayBuffer());
No Puppeteer, no chromium.executablePath, no waitForSelector timing guesses. The API handles rendering and waits for the selector you specify.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)