How to Use a Screenshot API with Node.js and Express
Build an Express endpoint that validates a URL, calls a hosted screenshot API, and returns a screenshot result safely. Includes runnable Node.js code, error handling, and a one-call ScreenshotNeo option.
A client sends a page URL to your Express route; the route validates it, calls a screenshot provider with a server-side API key, then returns the provider’s screenshot URL or image bytes. The provider’s response contract matters: some APIs return JSON containing a URL, while others return the image itself. This guide uses Screenshot API’s documented JSON response for the main runnable example, then shows how to adapt the route for binary responses.
Keep the provider key on the server, restrict who can call your route, set timeouts, and check the provider’s HTTP status before treating a capture as successful. Express’s express.json() middleware parses JSON request bodies, and route handlers are associated with an HTTP method and path. See the Express middleware guide and Express routing guide.
1. Install and configure the Express app
The example requires Node.js with built-in fetch support (Node.js 18 or newer), Express, and a Screenshot API key. The provider endpoint, bearer authorization header, request fields, and screenshotUrl response property follow the provider’s documented example; check its current contract before deployment.
mkdir screenshot-route
cd screenshot-route
npm init -y
npm install express
Save this as server.mjs:
import express from 'express';
const app = express();
app.use(express.json({ limit: '16kb' }));
const port = Number(process.env.PORT || 3000);
const apiKey = process.env.SCREENSHOT_API_KEY;
const providerEndpoint = 'https://api.screenshot-api.org/api/v1/screenshot';
const allowedHosts = (process.env.ALLOWED_HOSTS || '')
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
function validateTarget(raw) {
if (typeof raw !== 'string' || raw.length > 2048) {
return { error: 'url must be a string no longer than 2048 characters' };
}
let parsed;
try {
parsed = new URL(raw);
} catch {
return { error: 'url must be an absolute URL' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { error: 'only http and https URLs are allowed' };
}
if (parsed.username || parsed.password) {
return { error: 'URLs containing credentials are not allowed' };
}
if (allowedHosts.length && !allowedHosts.includes(parsed.hostname.toLowerCase())) {
return { error: 'this hostname is not allowed' };
}
return { url: parsed.toString() };
}
app.post('/screenshot', async (req, res) => {
if (!apiKey) {
return res.status(500).json({ error: 'Screenshot provider is not configured' });
}
const validation = validateTarget(req.body?.url);
if (validation.error) {
return res.status(400).json({ error: validation.error });
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 90000);
try {
const providerResponse = await fetch(providerEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: validation.url,
viewport: { width: 1280, height: 720 },
format: 'png',
fullPage: true,
}),
signal: controller.signal,
});
const payload = await providerResponse.json().catch(() => null);
if (!providerResponse.ok) {
const status = providerResponse.status;
if (status === 401) {
return res.status(502).json({ error: 'Screenshot provider rejected its API key' });
}
if (status === 400) {
return res.status(400).json({ error: 'Screenshot provider rejected the capture request' });
}
if (status === 429) {
return res.status(503).json({ error: 'Screenshot provider rate limit or quota reached' });
}
if (status === 502) {
return res.status(502).json({ error: 'Screenshot provider could not render the page' });
}
return res.status(502).json({ error: 'Screenshot provider returned an error' });
}
if (typeof payload?.screenshotUrl !== 'string') {
return res.status(502).json({ error: 'Screenshot provider returned an unexpected response' });
}
return res.json({ screenshotUrl: payload.screenshotUrl });
} catch (error) {
if (error.name === 'AbortError') {
return res.status(504).json({ error: 'Screenshot request timed out' });
}
return res.status(502).json({ error: 'Could not reach screenshot provider' });
} finally {
clearTimeout(timer);
}
});
app.use((err, req, res, next) => {
if (err instanceof SyntaxError) {
return res.status(400).json({ error: 'Request body must be valid JSON' });
}
console.error('Unhandled request error:', err.message);
return res.status(500).json({ error: 'Internal server error' });
});
app.listen(port, () => {
console.log(`Screenshot route listening on port ${port}`);
});
Start it with your key in the environment. Do not commit the key or return it to callers.
SCREENSHOT_API_KEY='your-provider-key' node server.mjs
2. Call the Express route
Your application exposes one stable interface to its callers. For example, send JSON with a url field:
curl -X POST http://localhost:3000/screenshot \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}'
On success, the route returns JSON such as {"screenshotUrl":"..."}. The client can display or download that URL according to your application’s needs. If your provider returns bytes rather than a URL, return those bytes with an appropriate content type instead of pretending the response is JSON.
3. Understand the provider request and available controls
The main example sends a POST request with bearer authentication and a JSON body. The documented Screenshot API options include output format, viewport, full-page capture, selector, wait conditions, delay, and timeout. The sample uses PNG, a 1280×720 viewport, and full-page capture. Consult the Screenshot API documentation for the current names, allowed values, and response shape before adding other fields.
| Concern | What to decide |
|---|---|
| Output | Choose a format that fits the consumer: PNG for crisp UI, JPEG or WebP where supported for smaller image payloads, or PDF if the provider supports document output. |
| Viewport and page scope | Set dimensions intentionally. Full-page capture can create a much larger image than a viewport shot; use a selector when only one region matters and the provider supports it. |
| Wait behavior | Use a provider-supported wait condition or delay for content that appears after initial load. Long waits increase request duration and can hit your route timeout. |
| Provider timeout | Keep the provider’s render timeout within your server and upstream proxy limits. Allow enough time for remote navigation, rendering, and transfer. |
| Authentication | Use the provider’s documented authorization mechanism. The reviewed provider examples use bearer tokens; other APIs may differ. |
4. Return binary image bytes when the API does
Some providers return the screenshot as raw bytes. RenderScreenshot documents a binary response and its Node.js example writes the bytes to a file. Screenshot API.net also documents direct raw image bytes. In that case, replace the JSON parsing and screenshotUrl handling with a binary response path. The exact content-type and error body are provider-specific, so confirm them in that provider’s docs.
// After fetch() and providerResponse.ok has been checked:
const imageBytes = Buffer.from(await providerResponse.arrayBuffer());
const contentType = providerResponse.headers.get('content-type') || 'image/png';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'private, no-store');
return res.status(200).send(imageBytes);
Do not call response.json() on a binary response. For APIs that return a URL inside JSON, do not treat that URL as image bytes without making a separate download request.
5. Validate URLs and protect the route
A screenshot endpoint that accepts arbitrary URLs can be abused as a proxy into destinations reachable by the provider or your own application. The example enforces absolute HTTP(S) URLs, rejects embedded credentials, limits URL length, and optionally allows only configured hostnames. These checks reduce risk but are not a complete defense against DNS changes, redirects, or provider-side network access.
- Require authentication or otherwise limit who can call your route; rate-limit callers and set usage quotas appropriate to your application.
- For known use cases, configure an allowlist such as
ALLOWED_HOSTS=example.com,docs.example.com. Decide whether subdomains should be allowed explicitly; the example matches exact hostnames. - Do not assume every provider blocks private, reserved, loopback, link-local, or cloud metadata addresses. Screenshot API.net documents refusing such destinations, but that is its service behavior, not a universal guarantee. Verify the provider you choose.
- Consider redirects: a public URL might redirect to a restricted destination. Use provider safeguards and application policy that account for redirect behavior.
- Return only safe error details. Never include authorization headers, API keys, internal network data, or full provider error payloads in client responses.
6. Handle failures, timeouts, and retries
Check the provider’s HTTP status before consuming a result. Screenshot API documents 401 for an invalid or missing key, 400 for an invalid request, 429 for rate limits or exhausted quota, and 502 for render failures. The sample maps those outcomes to safe application-level messages. Do not return a provider’s secret-bearing diagnostic payload directly to a caller.
| Symptom | Likely cause | Fix |
|---|---|---|
| Express returns 400 before contacting provider | Malformed JSON, missing URL, invalid URL, or unsupported scheme. | Send valid JSON with an absolute http: or https: URL and check the request body parser. |
| Provider 401 | Missing, revoked, or incorrectly configured API key. | Check the server environment variable and provider authorization format. Never move the key into browser code. |
| Provider 400 | Invalid option, unsupported format, or malformed provider request. | Compare fields and value types with current provider docs; log a sanitized diagnostic on the server. |
| Provider 429 | Rate limit reached or monthly quota exhausted. | Apply caller rate limits, queue work, reduce duplicate captures, or review provider quota and plan. |
| Provider 502 or render failure | The provider could not load or render the target page. | Check the target’s availability and access requirements; retry only if the error is transient and retries are bounded. |
| Express returns 504 | The route’s 90-second abort timer expired. | Review provider latency and its timeout controls; align route, proxy, and client timeouts, or use an asynchronous job pattern for long captures. |
| Success status but no screenshot URL | Provider response changed or the route is using the wrong response contract. | Inspect the sanitized response shape and update parsing to match the provider’s current docs. |
| Caller gets an empty or broken image | URL result expired, binary content was parsed as JSON, or the client cannot reach the returned URL. | Follow the provider’s URL lifetime and access rules, or proxy bytes with the correct content type when appropriate. |
Retries can duplicate billable work if the provider completed a capture but your app lost the response. Retry only on selected transient failures, cap attempts, add backoff, and use provider idempotency support if documented. Do not retry validation errors, unauthorized requests, or exhausted quota.
7. Plan for performance, reliability, and cost
- Latency: A request includes remote navigation, page loading, rendering, and result transfer. The reviewed documentation does not establish comparative speed, so measure your own target pages and deployment path before setting user-facing expectations.
- Concurrency: Bound simultaneous captures to avoid overwhelming your Express process or exceeding provider limits. For sustained or bursty workloads, put capture requests in a queue and return a job identifier rather than holding the client connection open.
- Caching: Identical captures can sometimes be reused, but only cache when freshness and privacy allow it. Cache keys should include all capture-affecting options, not just the URL.
- Payload size: Full-page images and high-resolution output increase transfer and memory costs. Prefer a targeted selector, viewport capture, or smaller format when that meets the need.
- Observability: Record duration, provider status, outcome category, and request correlation IDs. Redact credentials and consider whether submitted URLs contain sensitive query data.
- Provider limits and spend: Quotas and plan limits vary and change. Screenshot API’s documentation accessed in 2026 lists 60 requests per minute and 500 screenshots per month on its free plan; verify the current limit and pricing before relying on it.
8. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request can return an image or PDF. It removes cookie banners, newsletter popups, and chat widgets before the capture; bot checks, blank pages, and failed loads are never billed; and its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots per month with no card, and paid plans start at $5 for 3,000 screenshots. See the ScreenshotNeo site and API documentation.
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 with Express:
app.post('/screenshot-neo', async (req, res) => {
const url = req.body?.url;
if (typeof url !== 'string') {
return res.status(400).json({ error: 'url must be a string' });
}
const q = new URLSearchParams({
access_key: process.env.SCREENSHOTNEO_API_KEY,
url,
});
try {
const result = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`, {
signal: AbortSignal.timeout(90000),
});
if (!result.ok) {
return res.status(502).json({ error: 'ScreenshotNeo request failed' });
}
res.setHeader('Content-Type', result.headers.get('content-type') || 'image/webp');
return res.status(200).send(Buffer.from(await result.arrayBuffer()));
} catch {
return res.status(504).json({ error: 'Screenshot request timed out or could not complete' });
}
});
Every capture can also use options such as full-page or element capture, device presets, dark mode, custom CSS and JavaScript, selector waits, request blocking, caching, signed links, async jobs, and bulk capture. The documented parameter names used by other screenshot APIs also work, which can make switching easier. All features are available on every plan; yearly billing gives two months free. Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
9. Frequently asked questions
Should the Express route return a screenshot URL or the image itself?
Return the shape your client needs and your provider supports. A URL is convenient when the provider supplies a usable hosted result; bytes let your app control delivery and headers. Keep the response consistent for your own API callers.
Can I call the screenshot provider directly from browser JavaScript?
Keep the provider credential on the server. Have the browser call your Express route so the key is not exposed to users.
Does full-page capture always include content loaded on scroll?
That depends on the provider’s capture behavior and options. Check its documentation for lazy-loaded content handling, scrolling, and wait controls.
Is the example a production security boundary?
No single URL parser check is enough for an unrestricted public capture service. Add caller authentication, rate limits, a use-case-specific destination policy, and provider-side network protections appropriate to your application.


