DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to capture a full-page screenshot of a React or Next.js app

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" },
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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" },
  });
}
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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,
  }),
});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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)