ScreenshotNeo

BlogHow-to

How to Use a Website Screenshot API in a Next.js App

Generate website screenshots in Next.js with a server-side API route, private credentials, URL validation, and a browser-ready image response.

By the ScreenshotNeo team4 October 202611 min read

Use a server-side endpoint to call the screenshot provider. In the Next.js App Router, create a POST Route Handler at app/api/screenshot/route.ts; validate the requested URL and capture options there, read the provider API key from a server-only environment variable, and return the resulting image bytes. The browser calls your route, never the provider with your secret.

Route Handlers are public HTTP endpoints, so a route that accepts arbitrary URLs can be abused. Restrict destinations and formats, add your app’s authentication or rate limits where appropriate, and check your deployment platform’s request-duration limits. Next.js notes that some hosts run handlers as lambdas that can terminate long requests and may not support filesystem writes. Next.js backend guidance · Route Handler documentation.

1. Choose the response your app needs

For a simple preview, return image bytes with a correct Content-Type such as image/png. The client can turn the response into a Blob URL and display it. If your provider instead returns a hosted image URL, return JSON and render that URL. Check the provider’s current authentication, request fields, response format, limits, and media type; those details are provider-specific.

The example below uses a provider-neutral adapter contract: it expects an upstream endpoint, authorization scheme, request fields, and image response that you configure to match your chosen provider’s documentation. The sample capture fields are illustrative application fields, not universal screenshot API parameters. Do not deploy it unchanged without mapping that adapter to a real provider contract.

2. Add a server-only provider key

Set the credential in your deployment environment or local .env.local file. Do not prefix it with NEXT_PUBLIC_, pass it to a Client Component, or include it in browser-visible JSON.

# .env.local
SCREENSHOT_API_KEY=your_provider_key
SCREENSHOT_API_URL=https://provider.example/v1/screenshot
# Comma-separated exact hostnames your app is permitted to capture
SCREENSHOT_ALLOWED_HOSTS=example.com,www.example.com

Replace the example endpoint and host list with values appropriate to your provider and product. Keep .env.local out of version control.

3. Create an App Router Route Handler

Create app/api/screenshot/route.ts. This sample accepts a URL, width, height, and format, applies basic validation and an exact-host allowlist, calls the provider on the server, and returns image bytes. Map the marked provider request and authentication lines to the API you use. Check its actual response status and content type.

// app/api/screenshot/route.ts
import { NextRequest } from 'next/server';

export const runtime = 'nodejs';

const allowedFormats = new Set(['png', 'jpeg', 'webp']);
const maxDimension = 2400;

function isPrivateOrLocalHost(hostname: string): boolean {
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
  return (
    host === 'localhost' ||
    host.endsWith('.localhost') ||
    host === '::1' ||
    host === '0.0.0.0' ||
    host.startsWith('127.') ||
    host.startsWith('10.') ||
    host.startsWith('192.168.') ||
    /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
    host.startsWith('169.254.')
  );
}

export async function POST(request: NextRequest) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: 'Request body must be valid JSON.' }, { status: 400 });
  }

  if (!body || typeof body !== 'object' || !('url' in body)) {
    return Response.json({ error: 'A URL is required.' }, { status: 400 });
  }

  const input = body as { url?: unknown; width?: unknown; height?: unknown; format?: unknown };
  if (typeof input.url !== 'string' || input.url.length > 2048) {
    return Response.json({ error: 'URL must be a string of at most 2048 characters.' }, { status: 400 });
  }

  let target: URL;
  try {
    target = new URL(input.url);
  } catch {
    return Response.json({ error: 'Enter a valid absolute URL.' }, { status: 400 });
  }

  if (!['http:', 'https:'].includes(target.protocol)) {
    return Response.json({ error: 'Only HTTP and HTTPS URLs are supported.' }, { status: 400 });
  }
  if (target.username || target.password || isPrivateOrLocalHost(target.hostname)) {
    return Response.json({ error: 'Credentials and local or private network targets are not allowed.' }, { status: 400 });
  }

  const allowedHosts = (process.env.SCREENSHOT_ALLOWED_HOSTS ?? '')
    .split(',')
    .map((host) => host.trim().toLowerCase())
    .filter(Boolean);
  if (!allowedHosts.includes(target.hostname.toLowerCase())) {
    return Response.json({ error: 'This destination is not allowed.' }, { status: 403 });
  }

  const width = input.width ?? 1280;
  const height = input.height ?? 800;
  const format = input.format ?? 'png';
  if (!Number.isInteger(width) || !Number.isInteger(height) ||
      (width as number) < 1 || (height as number) < 1 ||
      (width as number) > maxDimension || (height as number) > maxDimension) {
    return Response.json({ error: `Width and height must be integers from 1 to ${maxDimension}.` }, { status: 400 });
  }
  if (typeof format !== 'string' || !allowedFormats.has(format)) {
    return Response.json({ error: 'Format must be png, jpeg, or webp.' }, { status: 400 });
  }

  const apiKey = process.env.SCREENSHOT_API_KEY;
  const providerUrl = process.env.SCREENSHOT_API_URL;
  if (!apiKey || !providerUrl) {
    return Response.json({ error: 'Screenshot provider is not configured.' }, { status: 500 });
  }

  let upstream: Response;
  try {
    // Provider-specific adapter: replace endpoint, auth, and body fields per its docs.
    upstream = await fetch(providerUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Accept': `image/${format}`,
      },
      body: JSON.stringify({
        url: target.toString(),
        width,
        height,
        format,
      }),
      cache: 'no-store',
      signal: AbortSignal.timeout(75_000),
    });
  } catch {
    return Response.json({ error: 'The screenshot provider could not be reached in time.' }, { status: 504 });
  }

  if (!upstream.ok) {
    // Log status and a request ID if available; do not return provider internals or the API key.
    return Response.json({ error: 'Screenshot generation failed.' }, { status: 502 });
  }

  const mediaType = upstream.headers.get('content-type')?.split(';')[0].trim();
  const expectedType: Record<string, string> = {
    png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp',
  };
  if (mediaType !== expectedType[format]) {
    return Response.json({ error: 'Provider returned an unexpected content type.' }, { status: 502 });
  }

  const bytes = await upstream.arrayBuffer();
  return new Response(bytes, {
    headers: {
      'Content-Type': mediaType,
      'Cache-Control': 'private, no-store',
      'X-Content-Type-Options': 'nosniff',
    },
  });
}

This example uses Node.js runtime explicitly and the standard Fetch API. If your provider returns JSON, a redirect, or a result URL instead of raw bytes, handle that documented response shape rather than treating every success as an image.

Why the URL checks matter

A server-side capture route can become a server-side request forgery (SSRF) path if users can direct it to internal services. The hostname checks above are a starting policy, not a complete defense for every network environment: DNS can resolve a public-looking hostname to a private address, redirects can lead elsewhere, and IPv6 address forms need careful handling. Prefer an exact allowlist of destinations your application needs. If arbitrary public URLs are a product requirement, enforce egress restrictions at the network or provider boundary and make sure redirect behavior cannot bypass the policy. Apply request authentication and per-user rate limits to prevent the endpoint from being used as an open proxy.

4. Call the route from a Client Component

The browser sends only capture inputs to your application endpoint. This component displays the returned image and revokes its temporary Blob URL when replaced or unmounted.

// app/components/ScreenshotButton.tsx
'use client';

import { useEffect, useState } from 'react';

export function ScreenshotButton() {
  const [imageUrl, setImageUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    return () => {
      if (imageUrl) URL.revokeObjectURL(imageUrl);
    };
  }, [imageUrl]);

  async function capture() {
    setBusy(true);
    setError(null);
    try {
      const response = await fetch('/api/screenshot', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          url: 'https://example.com',
          width: 1280,
          height: 800,
          format: 'png',
        }),
      });
      if (!response.ok) {
        const result = await response.json().catch(() => null);
        throw new Error(result?.error ?? `Capture failed (${response.status}).`);
      }
      const nextUrl = URL.createObjectURL(await response.blob());
      setImageUrl((previous) => {
        if (previous) URL.revokeObjectURL(previous);
        return nextUrl;
      });
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Capture failed.');
    } finally {
      setBusy(false);
    }
  }

  return (
    <section>
      <button onClick={capture} disabled={busy}>
        {busy ? 'Capturing…' : 'Capture screenshot'}
      </button>
      {error && <p role="alert">{error}</p>}
      {imageUrl && <img src={imageUrl} alt="Website screenshot" />}
    </section>
  );
}

Keep the target URL in application state or a form in a real product. The fixed example URL keeps the component focused on the request/response flow.

5. Add access control and operational limits

  • Authenticate users: require a logged-in session or another authorization check if captures are not intended for every visitor. Route Handlers are public endpoints by default.
  • Rate-limit requests: cap requests by account or IP and reject oversized request bodies. A browser button alone is not access control.
  • Constrain inputs: allow only formats and dimensions your product supports; place limits on capture count, page length, and any provider options you expose.
  • Use provider controls: pass custom headers or cookies only when needed and avoid accepting arbitrary caller-supplied credentials. Never log secrets or sensitive page contents.
  • Set a request deadline: coordinate your fetch timeout with the provider timeout and the host’s function duration limit. A client disconnect does not necessarily mean upstream work stops.
  • Choose storage deliberately: return bytes for an immediate preview, or store output in object storage and return an authorized URL when it must persist. Do not assume serverless local disk is durable.

6. Pages Router alternative

For a Pages Router project, put the endpoint under pages/api/screenshot.ts and use the API Routes request/response types. Keep the same validation, provider adapter, timeout, and access controls; the following only sketches the router-specific shape.

// pages/api/screenshot.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    return res.status(405).json({ error: 'Method not allowed.' });
  }
  // Apply the same parsing, validation, authentication, and allowlist policy
  // as the App Router example, then call the provider server-side.
  // For image bytes: set the provider's verified Content-Type and send a Buffer.
  return res.status(501).json({ error: 'Connect this route to your provider adapter.' });
}

That Pages Router snippet is intentionally not a complete provider implementation: request types and response helpers differ, while provider authentication and capture parameters remain provider-specific. See the Pages Router API Routes documentation.

7. Capture options and output choices

Expose only options your chosen provider supports and your application can safely validate. Common categories to consider include viewport dimensions, output format, full-page capture, element selection, device emulation, wait conditions, and custom page state. Each extra option adds validation and may affect capture duration or output size.

Need Implementation choice Check before enabling
Inline preview Return bytes with an image media type Provider really returns image bytes; enforce a size limit
Persistent or shareable result Store output and return a URL, or use a provider-hosted URL Access control, expiration, and provider URL semantics
More than one format Allowlist supported formats and map each to its media type Exact provider names and whether format affects billing
Full page or element capture Pass a documented capture option Maximum page dimensions, selector behavior, and time limits
Authenticated target page Use documented provider cookie/header support Secret handling, scope, and whether credentials may be exposed in logs

8. cURL, Python, and Node.js requests to your app

These examples call your Next.js route. The provider key remains on the server. They assume the app is running locally on port 3000 and the route accepts the JSON shape shown above.

cURL

curl -X POST http://localhost:3000/api/screenshot \
  -H 'Content-Type: application/json' \
  -o screenshot.png \
  --data '{"url":"https://example.com","width":1280,"height":800,"format":"png"}'

Python

import requests

response = requests.post(
    "http://localhost:3000/api/screenshot",
    json={
        "url": "https://example.com",
        "width": 1280,
        "height": 800,
        "format": "png",
    },
    timeout=90,
)
response.raise_for_status()
with open("screenshot.png", "wb") as image_file:
    image_file.write(response.content)

Node.js

const response = await fetch('http://localhost:3000/api/screenshot', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    url: 'https://example.com',
    width: 1280,
    height: 800,
    format: 'png',
  }),
});

if (!response.ok) {
  throw new Error(`Capture failed: ${response.status}`);
}
const image = Buffer.from(await response.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) =>
  writeFile('screenshot.png', image),
);

9. Performance, reliability, and cost

Each request depends on your Next.js endpoint, the provider, the target site, and the target’s assets. Large full-page captures and pages that wait for slow resources can take longer and return larger files. Use a bounded timeout, choose a wait strategy appropriate to the page, limit concurrency, and avoid repeatedly capturing an unchanged URL when a cache is suitable.

Route Handler caching is not enabled by default for dynamic request handling, and this POST flow should be treated as request-driven. If you add caching, key it on all output-affecting inputs and consider whether pages contain private or changing data. Do not cache personalized output publicly.

There is no universal cost or latency figure for screenshot APIs. Compare a hosted provider’s pricing and request limits with the cost of running and maintaining browser infrastructure yourself. Also check provider geography, retention, formats, concurrency, and failure semantics. Confirm your deployment host’s function duration and payload limits before relying on synchronous captures. If captures can exceed those limits, use a documented asynchronous job flow and retrieve results after completion.

10. Troubleshooting

Symptom Likely cause Fix
400 from your route Malformed JSON, relative URL, unsupported scheme, or invalid dimensions/format Send an absolute HTTP(S) URL and allowed integer dimensions and format
403 destination rejected Hostname is not in the configured exact allowlist Add the intended hostname to server configuration; do not broadly allow internal destinations
500 provider not configured Environment variable missing or deployment not restarted after configuration Set the server-side values in the active environment and redeploy/restart
502 capture failed Provider rejected authentication/options, target failed, or upstream returned an error Check provider status, request ID, and server logs; verify contract and key without exposing it
Unexpected content type Provider returned JSON or another format rather than requested bytes Inspect its documented response and implement that response mode explicitly
504 or host timeout Capture exceeded route or deployment duration Reduce wait/capture size, adjust compatible limits, or use an asynchronous provider workflow
Browser shows a network error Wrong route origin, deployment error, or cross-origin setup issue Call the same-origin /api/screenshot path and inspect server logs and response status
Image appears broken JSON/error bytes saved as an image, wrong media type, or truncated body Check status and Content-Type before displaying or saving bytes
Screenshot is blank or incomplete Target blocks the renderer, content is lazy-loaded, or capture occurs before rendering finishes Use provider-supported wait conditions or scroll/full-page options; inspect target restrictions

11. Or skip the browser setup

With ScreenshotNeo, your Next.js server can make one GET request to receive a screenshot. Keep this call in the Route Handler and keep the access key in a server-only environment variable. The options below are documented by ScreenshotNeo; see the ScreenshotNeo API documentation for the current parameter details.

// In server-side Next.js code
const q = new URLSearchParams({
  access_key: process.env.SCREENSHOTNEO_API_KEY!,
  url: 'https://stripe.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned ${res.status}`);
const image = await res.arrayBuffer();
return new Response(image, {
  headers: { 'Content-Type': res.headers.get('content-type') ?? 'image/webp' },
});

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, and cache hits are not billed. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is available on every plan. Learn about ScreenshotNeo, then sign up free for 1,000 screenshots a month with no card.

FAQ

Can I call the screenshot provider directly from the browser?

Only if the provider explicitly supports a safe, restricted client-side credential model. Otherwise, keep the secret on the server and proxy the request through your own endpoint.

Should I use a Route Handler or a Server Action?

A Route Handler provides a clear HTTP endpoint for browser, mobile, or external clients and can return image bytes. A Server Action can fit an app-internal form workflow, but it does not remove the need for validation, access control, and server-side secret handling.

Can I screenshot a page behind a login?

Only if your provider documents support for the required cookies or authorization headers. Treat those credentials as secrets, limit their scope, and avoid returning or logging them.

Does the route need to write the image to disk?

No. For an immediate preview, stream or return the bytes. For durable sharing, use storage designed for persistent files or a provider-supported hosted result URL.