How to Use a Screenshot API with Next.js
Build a Next.js endpoint that returns a webpage screenshot. Compare Playwright with hosted APIs, with runnable TypeScript, cURL, Python, and Node.js examples.
A Next.js application can expose a server-side endpoint that accepts a target URL, renders that page with Playwright or a hosted screenshot API, and returns the resulting image bytes. Keep provider credentials on the server, validate the requested URL, bound the capture time, and return an image content type only when the upstream response is actually an image.
This guide uses an App Router Route Handler and a hosted screenshot API for the shortest implementation. It also shows the Playwright alternative, image options, validation, troubleshooting, and operational tradeoffs. Route Handlers live in the app directory and use Web Request and Response APIs; see the official Route Handler documentation.
1. Choose how the page will be rendered
The browser must render the destination before it can be captured. You can operate that browser yourself with Playwright, or send the request to a hosted screenshot API.
| Approach | What your app does | Good fit when |
|---|---|---|
| Playwright | Starts or connects to a browser, navigates to the target, and calls page.screenshot(). |
You need direct browser automation control and can run and maintain the browser runtime. |
| Hosted REST API | Sends a URL and capture options to an authenticated endpoint and relays the returned image bytes. | You want to delegate browser execution and accept an external provider dependency. |
Playwright documents both saving a screenshot to a file and returning screenshot data for later processing. A hosted service may expose a simpler HTTP interface; for example, Browserless documents an authenticated POST to its /screenshot endpoint that returns image content. These interfaces and options are provider-specific. Playwright screenshot guide · Playwright Page API · Browserless Screenshot API.
2. Create a server-side Next.js screenshot endpoint
The sample below accepts JSON via POST, validates an HTTP(S) URL and optional hostname allowlist, then calls a provider from the server. Configure the provider URL and token from your provider’s current documentation; request field names and authentication formats vary. This example assumes the provider accepts {"url":"...","options":{"fullPage":true}} and a bearer token, and that it returns image bytes. Replace those provider-specific details with the service you use.
Set environment variables
# .env.local
SCREENSHOT_API_URL=https://your-provider.example/screenshot
SCREENSHOT_API_TOKEN=replace-with-a-private-token
# Optional comma-separated host allowlist, for example:
SCREENSHOT_ALLOWED_HOSTS=example.com,docs.example.com
Never prefix a private provider token with NEXT_PUBLIC_: variables with that prefix are intended for browser-visible code. Keep this request in a server Route Handler.
Add the route
// app/api/screenshot/route.ts
export const runtime = 'nodejs';
const MAX_URL_LENGTH = 2048;
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
const TIMEOUT_MS = 60_000;
function jsonError(message: string, status: number) {
return Response.json({ error: message }, { status });
}
function allowedHost(hostname: string): boolean {
const configured = process.env.SCREENSHOT_ALLOWED_HOSTS;
if (!configured) return true;
const hosts = configured.split(',').map((host) => host.trim().toLowerCase()).filter(Boolean);
const host = hostname.toLowerCase();
return hosts.some((entry) => host === entry || host.endsWith(`.${entry}`));
}
export async function POST(request: Request) {
let input: unknown;
try {
input = await request.json();
} catch {
return jsonError('Request body must be valid JSON.', 400);
}
const rawUrl = (input as { url?: unknown })?.url;
if (typeof rawUrl !== 'string' || rawUrl.length > MAX_URL_LENGTH) {
return jsonError('Provide a URL string no longer than 2048 characters.', 400);
}
let target: URL;
try {
target = new URL(rawUrl);
} catch {
return jsonError('Provide a valid absolute URL.', 400);
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return jsonError('Only HTTP and HTTPS URLs are supported.', 400);
}
if (target.username || target.password) {
return jsonError('URLs containing embedded credentials are not accepted.', 400);
}
if (!allowedHost(target.hostname)) {
return jsonError('This hostname is not allowed.', 403);
}
const endpoint = process.env.SCREENSHOT_API_URL;
const token = process.env.SCREENSHOT_API_TOKEN;
if (!endpoint || !token) return jsonError('Screenshot provider is not configured.', 500);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const upstream = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'image/png,image/jpeg,image/webp'
},
body: JSON.stringify({ url: target.href, options: { fullPage: true, format: 'png' } }),
signal: controller.signal,
cache: 'no-store'
});
if (!upstream.ok) {
// Do not relay provider error pages or expose provider credentials/details.
return jsonError(`Screenshot provider returned HTTP ${upstream.status}.`, 502);
}
const contentType = (upstream.headers.get('content-type') || '').split(';')[0].toLowerCase();
if (!['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) {
return jsonError('Provider response was not a supported image.', 502);
}
const bytes = await upstream.arrayBuffer();
if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) {
return jsonError('Screenshot was empty or exceeded the response size limit.', 502);
}
return new Response(bytes, {
headers: {
'Content-Type': contentType,
'Content-Length': String(bytes.byteLength),
'Cache-Control': 'private, no-store'
}
});
} catch (error) {
const timedOut = error instanceof Error && error.name === 'AbortError';
return jsonError(timedOut ? 'Screenshot timed out.' : 'Could not reach screenshot provider.', timedOut ? 504 : 502);
} finally {
clearTimeout(timer);
}
}
Run the app with your usual Next.js development command, then POST JSON to /api/screenshot. This synchronous example buffers the image in memory. The byte limit is an application safeguard; choose one appropriate for your hosting environment and expected page lengths. In production, also apply user authentication, per-user rate limits, and a policy for which destinations are permitted.
curl -X POST http://localhost:3000/api/screenshot \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}' \
-o page.png
3. Call your endpoint from a Next.js client
Use a client event handler when a user asks for the capture. The browser calls your application endpoint; the provider secret stays server-side.
'use client';
import { useState } from 'react';
export function ScreenshotButton() {
const [imageUrl, setImageUrl] = useState<string>();
const [error, setError] = useState('');
async function capture() {
setError('');
setImageUrl(undefined);
const response = await fetch('/api/screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' })
});
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
setError(problem.error || `Capture failed (${response.status})`);
return;
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
}
return (
<div>
<button onClick={capture}>Capture page</button>
{error && <p role="alert">{error}</p>}
{imageUrl && <img src={imageUrl} alt="Captured webpage" />}
</div>
);
}
For repeated captures, revoke old object URLs with URL.revokeObjectURL() when they are no longer displayed. For a page that should share or revisit the result, store the image in object storage and return an application-controlled URL instead of holding it only in browser memory.
4. Use Playwright when you want to manage the browser
Playwright’s basic flow is navigation followed by page.screenshot(). Install Playwright and its browser runtime according to the official Playwright installation guide. The following server-only helper returns a PNG buffer; it can replace the upstream fetch portion of the route. The Playwright package and browser must be available in the deployment runtime.
// lib/capture.ts
import { chromium } from 'playwright';
export async function captureWithPlaywright(url: string): Promise<Buffer> {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.locator('main').waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {});
return await page.screenshot({ type: 'png', fullPage: true, animations: 'disabled' });
} finally {
await browser.close();
}
}
In a Route Handler, validate the URL before calling this helper, then return the buffer:
const png = await captureWithPlaywright(target.href);
return new Response(new Uint8Array(png), {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-store' }
});
For higher concurrency, launching a new browser for every request can consume substantial memory and startup time. Evaluate browser reuse, isolated browser contexts, concurrency limits, cleanup after errors, and the process limits of your deployment host. Do not assume that a serverless runtime permits a browser binary or long-running capture; check the host’s current runtime constraints.
5. Select capture settings for the page
Pass only settings supported by the rendering engine or provider you chose. Playwright and hosted services use different option names and support different subsets; consult the specific API reference before forwarding user-supplied options.
| Need | Typical control | Considerations |
|---|---|---|
| Long page | Full-page capture | Lazy images may load only after scrolling. A full-page flag alone may not trigger every site’s lazy-loading behavior. |
| Specific region | Clip rectangle or selector capture | Wait for the target element and handle missing selectors as a clear error. |
| Smaller output | JPEG or WebP; quality where supported | Quality is commonly relevant to lossy formats; verify provider semantics. Check returned content type instead of assuming it matches the request. |
| Consistent layout | Viewport width and height; device scale factor | Responsive breakpoints and retina scale affect layout and pixel dimensions. |
| Dynamic page | Wait for a selector, a delay, or network idle | Network idle may never occur on pages with persistent connections. Prefer a meaningful selector where possible. |
| Private target | Cookies or request headers | Keep secrets out of logs and client input; avoid returning captures that contain another user’s private data. |
Browserless documents controls such as full-page output, format, quality, clipping, viewport, selector capture, and waiting behavior. Its guide also notes that lazy-loaded content may require scrolling before a full-page capture. Treat these as Browserless endpoint details, not universal parameters: Browserless Screenshot API options.
6. Return bytes, or return a URL
Returning the image bytes directly is straightforward for a small, synchronous request. Set the correct Content-Type, avoid caching personalized results publicly, and consider a response size limit. The client can display the bytes as a blob URL or save them as a file.
Return a URL instead when captures need reuse, long retention, asynchronous execution, or sharing. A robust pattern is: submit a capture job, persist its state, store the finished image, and return a URL with an appropriate access policy. This is application architecture guidance; whether a provider supports jobs, storage, or signed URLs depends on that provider.
For repeat requests to the same public page, cache by a key that includes the normalized target URL and all settings that affect pixels. Set an explicit TTL and avoid sharing cache entries across users when cookies, headers, or access permissions vary.
7. Security and reliability checklist
- Accept only absolute HTTP or HTTPS URLs; reject embedded credentials and unexpected schemes.
- Constrain destinations with a hostname allowlist where your product can do so. If arbitrary public URLs are a feature, defend against server-side request forgery: resolve and reject loopback, private, link-local, and reserved addresses, and account for redirects and DNS changes. URL string checks alone are not a complete SSRF defense.
- Never expose provider tokens in client code, query parameters visible to the browser, or error responses.
- Authenticate your own endpoint and add rate and concurrency limits; otherwise it can become an open proxy that consumes provider quota.
- Use a bounded timeout and check upstream status, content type, body size, and empty responses before returning image bytes.
- Decide whether the destination’s data may be sent to a third party. Review the provider’s data handling and service terms before sending sensitive pages.
- Log a request identifier, duration, provider status, and failure category. Avoid logging tokens, cookies, authorization headers, or full sensitive URLs.
- Test pages with lazy content, long scroll height, redirects, access controls, and automation checks. A browser capture may show a CAPTCHA or access-denied page instead of the intended page.
These controls are general application security and reliability guidance. Browserless documents blank captures, CAPTCHA pages, 403/access-denied pages, and missing or broken elements as possible automation or rendering outcomes; a mitigation is not guaranteed to work for every site.
8. Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
| 400 from your route | Malformed JSON, missing URL, relative URL, or unsupported scheme. | Send valid JSON with an absolute HTTP(S) URL and inspect the route’s error body. |
| 502 from your route | Provider returned an error, a non-image response, or an unsupported content type. | Check provider status and logs server-side; confirm endpoint, authentication, request schema, and accepted formats. |
| 504 / timeout | Destination is slow, capture waits indefinitely, or timeout is too short. | Use a meaningful readiness selector, increase the bounded limit only within your hosting limits, or use an asynchronous job design. |
| Blank or nearly blank screenshot | Navigation failed, content has not rendered, scripts failed, or automation received an interstitial. | Inspect the actual browser/provider result, wait for a known element, and check whether the destination presents a CAPTCHA or access denial. |
| Images or lower sections missing | Lazy loading, slow resources, or full-page capture did not scroll the page. | Scroll through the page or wait for required assets before capture; provider support varies. |
| Missing selector or clipped output | Selector is absent, hidden, or outside the expected viewport. | Wait for the selector, verify it against the rendered DOM, and distinguish selector timeout from capture failure. |
| Works locally but fails after deployment | Browser binary/runtime incompatibility, memory or execution limit, or environment variables missing. | Check deployment runtime support and logs; verify server-side env configuration. For managed browser rendering, check provider reachability and credentials. |
| Image displays as broken or downloaded incorrectly | Wrong response content type, error JSON treated as an image, or client did not handle bytes. | Check response status and headers before creating a blob; preserve the actual image content type. |
| Too many captures or unexpected cost | Public endpoint abuse, retries, or repeated uncached requests. | Require authentication, rate-limit, bound concurrency, deduplicate identical work, and cache safely. |
9. Performance, reliability, and cost
Capture time depends on target page behavior, rendering settings, browser startup, provider/network latency, and image size; there is no universal timing figure. Large full-page images consume more memory and bandwidth than a small viewport crop. Use the narrowest useful capture, choose a suitable format, cap output size, and avoid waiting for every network connection when a specific page element signals readiness.
With self-managed Playwright, account for browser installation, runtime compatibility, memory, concurrency, and cleanup. With a hosted API, browser operations move outside your application, but your route depends on provider availability, authentication, limits, latency, and data policies. Verify those terms and prices in current provider documentation; the research sources do not establish them. For either approach, add bounded retries only for transient failures, and avoid retrying deterministic invalid URLs or access denials.
A synchronous route occupies a request while the page renders and image bytes transfer. If the target can take longer than your platform request limit, queue work and let the client poll or receive a completion notification. Cache only when the target and capture settings are safe to reuse.
10. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. One GET request returns a PNG, JPEG, WebP, or PDF. Its capture options include full-page output, selector capture, device presets and custom viewports, dark mode, custom CSS and JavaScript, cookies and headers, waits, blocking rules, caching, async jobs, and bulk capture. See the ScreenshotNeo API documentation for parameter details.
// app/api/screenshot/route.ts (server-side call)
export async function GET() {
const q = new URLSearchParams({
access_key: process.env.SCREENSHOTNEO_API_KEY!,
url: 'https://stripe.com'
});
const upstream = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`, { cache: 'no-store' });
if (!upstream.ok) return Response.json({ error: 'Screenshot request failed' }, { status: 502 });
const contentType = upstream.headers.get('content-type') || 'image/webp';
return new Response(await upstream.arrayBuffer(), {
headers: { 'Content-Type': contentType, 'Cache-Control': 'private, no-store' }
});
}
Keep the access key in a server environment variable and validate the requested target as shown earlier. The same endpoint can be called using cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
11. FAQ
Can the browser call the screenshot provider directly?
It can only do so safely if the provider is designed for public client credentials or browser access. For a private token, proxy the request through a server Route Handler.
Should the endpoint use GET or POST?
POST is convenient for a target URL and capture options in a JSON body and avoids putting those values in your own endpoint’s query string. Follow the chosen provider’s documented method for its upstream request.
Can I capture pages behind a login?
Only when you have authorized access and provide the necessary session state securely. Treat captured output as private and prevent shared caching across accounts.
Does full-page mode guarantee every element is loaded?
No. Lazy loading and site-specific scripts can require scrolling or explicit readiness waits, and access controls can change what the browser sees.
Primary references: Next.js Route Handlers, Playwright Screenshots, Playwright Page API, Browserless Screenshot API, and Browserless REST APIs.


