ScreenshotNeo

BlogHow-to

How to Add Website Screenshot Cards to a Next.js App with URL2PNG

Build screenshot cards in Next.js with URL2PNG: sign requests on the server, tune capture options, handle caching, and render accessible previews.

By the ScreenshotNeo team4 October 202614 min read

Short answer: add a server-side Next.js route that builds and signs a URL2PNG v6 request, then render the resulting image URL in a screenshot card. Keep the URL2PNG secret on the server, validate target URLs, and decide how often a card should refresh before choosing cache settings. URL2PNG’s reviewed documentation provides Node.js examples, but no Next.js-specific integration recipe or Next.js package.

This guide adapts URL2PNG’s documented v6 signing design to a Next.js App Router route. The code is an implementation example based on that design, not a vendor-published or tested Next.js integration. Check the current URL2PNG Quickstart when implementing, especially if request encoding details have changed.

1. How the request and card fit together

A card has two distinct pieces: your app’s presentation and the remotely generated screenshot. Your server accepts a target URL and capture options, constructs the URL2PNG query string, derives its token, and returns a signed image URL. The browser loads that URL as the card image. URL2PNG renders the target site; your app controls the card dimensions, cropping, loading state, and alternative text.

  1. The visitor submits or opens a target URL.
  2. Your Next.js server validates it and selects allowed capture options.
  3. The server makes a deterministic query string and signs it using the account secret.
  4. The client renders the returned signed URL in an image element.

The v6 request includes the API key in the path, an MD5 token, a PNG endpoint, and the query string. URL2PNG documents the token as the MD5 hash of the complete query string followed by the account secret. Because the token is derived from a secret, construct it on the server only. See the request anatomy in URL2PNG’s documentation.

2. Configure a Next.js App Router project

This example uses built-in Node.js crypto and the App Router. It does not require the URL2PNG npm package. Keep the route on the Node.js runtime because it uses node:crypto.

# .env.local
URL2PNG_API_KEY=PXXXXXXXXXXXXX
URL2PNG_SECRET=S_XXXXXXXXXXXX

Do not prefix these variables with NEXT_PUBLIC_. That prefix exposes values to browser code. Restart the development server after changing environment variables.

Build and sign the request

Create app/api/screenshot/route.ts. This example sorts parameter names, encodes values using URLSearchParams, and signs the exact serialized query string it puts in the URL. The vendor’s quickstart examples sort options in some language examples and construct the full query string before hashing; confirm encoding compatibility against the current documentation for the URLs and option values you use.

// app/api/screenshot/route.ts
import { createHash } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'nodejs';

const allowedHosts = new Set(['example.com', 'www.example.com']);

function validTarget(value: string): URL | null {
  try {
    const target = new URL(value);
    if (target.protocol !== 'https:' && target.protocol !== 'http:') return null;
    if (!allowedHosts.has(target.hostname)) return null;
    if (target.username || target.password) return null;
    return target;
  } catch {
    return null;
  }
}

function signedUrl(target: string, options: Record<string, string>): string {
  const apiKey = process.env.URL2PNG_API_KEY;
  const secret = process.env.URL2PNG_SECRET;
  if (!apiKey || !secret) throw new Error('URL2PNG credentials are not configured');

  // Include url and all options before signing. Do not change parameter order
  // or encoding after computing the token.
  const params = new URLSearchParams();
  for (const key of Object.keys({ url: target, ...options }).sort()) {
    params.set(key, key === 'url' ? target : options[key]);
  }
  const query = params.toString();
  const token = createHash('md5').update(query + secret, 'utf8').digest('hex');
  return `https://api.url2png.com/v6/${encodeURIComponent(apiKey)}/${token}/png/?${query}`;
}

export async function GET(request: NextRequest) {
  const raw = request.nextUrl.searchParams.get('url');
  if (!raw) return NextResponse.json({ error: 'Provide a url parameter.' }, { status: 400 });

  const target = validTarget(raw);
  if (!target) {
    return NextResponse.json({ error: 'The URL must use HTTP or HTTPS and match an allowed host.' }, { status: 400 });
  }

  try {
    const imageUrl = signedUrl(target.toString(), {
      viewport: '1280x800',
      thumbnail_max_width: '640',
      fullpage: 'false',
      ttl: '2592000',
    });
    return NextResponse.json({ imageUrl }, {
      headers: { 'Cache-Control': 'private, max-age=300' },
    });
  } catch (error) {
    console.error('Could not create screenshot URL', error);
    return NextResponse.json({ error: 'Screenshot configuration is unavailable.' }, { status: 500 });
  }
}

Important security note: the sample uses an allowlist so callers can only request screenshots of the two example hosts. Replace it with your product’s intended policy. If your app needs arbitrary public websites, block loopback, private, link-local, and internal network destinations, and re-check DNS resolution to defend against server-side request forgery and DNS rebinding. Apply request limits and authentication where appropriate. Do not accept arbitrary options and pass them through unchecked.

Encoding note: the query string is part of the signature. The precise bytes used for signing must match the query URL sent to URL2PNG. Avoid independently encoding the target, sorting only some parameters, changing spaces from %20 to +, or appending options after generating the token. If a signed request is rejected, compare the literal query string and hashing input with the current URL2PNG guide. The docs’ examples are not all identical about ordering and URL construction.

Render a card

Create a client component that calls the route and displays the returned URL. In production, consider generating the signed URL from a server component or server action when possible so the destination is not exposed as an API that arbitrary users can repeatedly invoke.

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

import { useState } from 'react';

type Props = { targetUrl: string; title: string };

export function ScreenshotCard({ targetUrl, title }: Props) {
  const [imageUrl, setImageUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function loadScreenshot() {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch(`/api/screenshot?url=${encodeURIComponent(targetUrl)}`);
      const result = await response.json();
      if (!response.ok) throw new Error(result.error || 'Could not create screenshot.');
      setImageUrl(result.imageUrl);
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Could not create screenshot.');
    } finally {
      setLoading(false);
    }
  }

  return (
    <article className="screenshot-card">
      <h2>{title}</h2>
      {imageUrl ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={imageUrl} alt={`Screenshot preview of ${title}`} loading="lazy" />
      ) : (
        <button type="button" onClick={loadScreenshot} disabled={loading}>
          {loading ? 'Preparing preview…' : 'Load screenshot'}
        </button>
      )}
      {error && <p role="alert">{error}</p>}
    </article>
  );
}
/* app/globals.css */
.screenshot-card { overflow: hidden; border: 1px solid #ddd; border-radius: 12px; }
.screenshot-card img { display: block; width: 100%; aspect-ratio: 16 / 10; object-fit: cover; }
.screenshot-card h2 { margin: 1rem; font-size: 1rem; }
.screenshot-card button { margin: 1rem; }

For a purely visual preview, empty alternative text may be appropriate if the card title already identifies the link and the image adds no unique information. If the screenshot conveys information, supply useful alternative text. Use a fixed aspect ratio to prevent layout shifts. object-fit: cover crops the rendered image to the card; it does not change what URL2PNG captures.

3. Configure capture size, freshness, and presentation

URL2PNG’s quickstart documents these settings. Defaults and available limits can change, so confirm them in the current Quickstart.

Option What it controls When to use it
viewport Browser viewport dimensions, such as 1280x800. The quickstart lists 1480×1037 as a default and gives a 5000×5000 maximum in an example. Choose dimensions that match the layout you want users to preview. A desktop viewport helps show desktop navigation; a narrow viewport shows a mobile layout.
thumbnail_max_width Scales output to a maximum width, for example 640. Use a width close to the displayed card’s pixel size to avoid unnecessarily large assets. Scaling output does not set the CSS card size.
fullpage Attempts to capture the entire document instead of only the viewport. Documented default: false. Use viewport-only for compact cards; use full-page when the full document matters. Long pages may produce tall images that are hard to read in small cards.
ttl Cache lifetime in seconds. Documented default: 2592000 seconds (30 days). Set according to how quickly the target content changes. A longer TTL favors reuse; a shorter TTL favors freshness.
unique Changes the request key to ask for a fresh screenshot; the guide suggests varying this value, such as using a timestamp. Keep it stable for reusable cached previews. Change it only when you intentionally want a new capture; a per-request timestamp defeats reuse.
delay Waits a specified number of seconds after document readiness and asset loading. Use a small delay only for content that appears after normal page loading. It adds wait time and is not a general fix for inaccessible pages.
custom_css_url Loads a CSS URL into the target page. Use to adjust the captured presentation when you control a suitable stylesheet URL. The guide’s example uses a remote stylesheet.
say_cheese Waits for the target page’s #url2png-cheese element when enabled. Useful when you control the target and can add the marker after its content is ready.
accept_languages Overrides the Accept-Language header; documented default is en-US,en;q=0.8. Request the language variant your card should show.
user_agent Sets a custom user-agent header. Use only when you need a particular target presentation and have confirmed the target’s behavior.

Options that affect rendering should be considered part of the screenshot identity. If you change the viewport, language, full-page setting, or other visual inputs, use a distinct cache key or refresh policy so you do not reuse an image made with different settings.

4. Use URL2PNG from cURL, Python, and Node.js

These standalone examples show the same signing model as the route. Query serialization must be consistent between the digest and request URL. URL2PNG’s official guide includes Node.js package methods such as buildURL and readURL; confirm the package’s current API and encoding behavior before adopting it. These examples are direct HTTP request construction examples, not a Next.js integration supplied by URL2PNG.

cURL

cURL can send a request once you have the exact signed URL. Do not put the secret in a browser or a publicly shared shell history. This Bash example builds a sorted query and token using OpenSSL; adjust and verify the encoding against URL2PNG’s current documentation before relying on it.

API_KEY='PXXXXXXXXXXXXX'
SECRET='S_XXXXXXXXXXXX'
TARGET='https://example.com/'
QUERY="fullpage=false&thumbnail_max_width=640&url=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))')&viewport=1280x800"
TOKEN=$(printf '%s' "${QUERY}${SECRET}" | openssl dgst -md5 | awk '{print $NF}')
curl --fail --location "https://api.url2png.com/v6/${API_KEY}/${TOKEN}/png/?${QUERY}" --output preview.png

The shell line above is intended for a controlled example; robust URL construction should use one language’s URL encoder for all parameter names and values, then hash that exact query string. Avoid manually interpolating untrusted input into shell commands.

Python

import hashlib
from urllib.parse import urlencode
import requests

api_key = "PXXXXXXXXXXXXX"
secret = "S_XXXXXXXXXXXX"  # Keep on the server; do not commit credentials.
options = {
    "url": "https://example.com/",
    "fullpage": "false",
    "thumbnail_max_width": "640",
    "viewport": "1280x800",
}
query = urlencode(sorted(options.items()))
token = hashlib.md5((query + secret).encode("utf-8")).hexdigest()
request_url = f"https://api.url2png.com/v6/{api_key}/{token}/png/?{query}"

response = requests.get(request_url, timeout=90)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if "image" not in content_type:
    raise RuntimeError(f"Expected an image response, got {content_type!r}")
with open("preview.png", "wb") as image_file:
    image_file.write(response.content)

Node.js

import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';

const apiKey = process.env.URL2PNG_API_KEY;
const secret = process.env.URL2PNG_SECRET;
if (!apiKey || !secret) throw new Error('Set URL2PNG_API_KEY and URL2PNG_SECRET');

const options = {
  url: 'https://example.com/',
  fullpage: 'false',
  thumbnail_max_width: '640',
  viewport: '1280x800',
};
const query = new URLSearchParams(
  Object.entries(options).sort(([a], [b]) => a.localeCompare(b)),
).toString();
const token = createHash('md5').update(query + secret, 'utf8').digest('hex');
const imageUrl = `https://api.url2png.com/v6/${apiKey}/${token}/png/?${query}`;
const response = await fetch(imageUrl);
if (!response.ok) throw new Error(`URL2PNG returned HTTP ${response.status}`);
const type = response.headers.get('content-type') || '';
if (!type.includes('image')) throw new Error(`Expected image response, got ${type}`);
await writeFile('preview.png', Buffer.from(await response.arrayBuffer()));

URL2PNG’s quickstart also shows using its url2png package: initialize it with the API key and private key, call buildURL(target, options) to obtain an image URL, or pipe readURL(target, options) to a file or HTTP response. The reviewed guide does not document a Next.js-specific package or integration.

5. Cache strategy, freshness, and render usage

The URL2PNG guide documents a 30-day default TTL. Its plans page says cached image loads do not count against plan usage, while newly generated screenshots count as renders. Thus, choose a stable request for ordinary card views and vary unique when you deliberately want another capture. Do not generate a timestamp for every page view unless that freshness is worth a new render. Check the live plans page for current quotas, prices, and terms; they can change.

  • Mostly static destinations: keep the same parameters and use a longer TTL.
  • Frequently updated pages: choose a shorter TTL or a controlled refresh cadence.
  • Explicit refresh action: vary unique in a deliberate way, such as a refresh version or time bucket, rather than on every render.
  • Different visual variants: treat viewport, language, and capture options as distinct screenshot variants.

Your Next.js route can also cache its JSON response, as the sample’s Cache-Control header illustrates. That cache is separate from URL2PNG’s screenshot cache. Avoid caching a signed URL beyond the period in which you intend it to be usable, and avoid browser caching rules that conceal an intentional refresh.

6. Reliability, performance, and cost considerations

  • Latency: a cold render depends on the target site loading and the screenshot service capturing it. A cached image can avoid another render, but actual response times depend on the service and destination. Do not promise a fixed time without measuring your own workload.
  • Card loading: use lazy loading for cards below the fold, reserve space with an aspect ratio, and avoid requesting full-page captures for every small preview.
  • Resilience: show a placeholder and a retry path when URL creation or image loading fails. A successful response from your route only means a URL was generated; the remote image can still fail later.
  • Cost: distinguish image retrievals from fresh screenshot renders. The vendor says cached loads do not count against plan usage and fresh screenshots count as renders. Review current terms and pricing on the live plans page rather than relying on old price figures.
  • Abuse control: arbitrary URL capture can consume resources and expose internal services if your server fetches or validates targets unsafely. Use an allowlist where practical, authentication, rate limits, and careful URL/network validation.

7. Troubleshooting

Symptom Likely cause What to check
URL2PNG rejects the request or returns an error instead of an image The token does not match the query string; parameters were reordered, encoded differently, omitted from the digest, or changed after signing. Log the query string without the secret, recompute the MD5 over that exact string plus the secret, and compare with the v6 request format in the current docs.
Target URL with spaces, ampersands, or query parameters breaks The target URL was not encoded as one query parameter, or it was double-encoded. Use a URL encoder once for the target value. Ensure target query separators become part of the encoded url value rather than top-level URL2PNG options.
API secret appears in client bundles or browser network calls Credentials were placed in a client component, exposed with NEXT_PUBLIC_, or the signature was generated in the browser. Move signing to a server route or server component, remove the public variable, rotate exposed credentials, and inspect built assets.
Next.js reports that node:crypto is unavailable The route was bundled for an Edge runtime. Set export const runtime = 'nodejs' for the route.
The card is blank although the route returned JSON The remote image request may be failing, blocked, or returning an error body; the generated URL alone does not prove the capture succeeded. Open the image URL in a controlled server-side check, inspect status and content type, and show an image-level error state in the UI.
The screenshot shows a loading skeleton or an incomplete animation The page may render after the normal readiness point. Use a small delay, or use say_cheese with the documented marker when you control the target page.
The image looks blurry or the layout differs from the live site Thumbnail scaling, viewport dimensions, responsive breakpoints, or user-agent behavior changed the result. Adjust viewport to the intended layout, choose an appropriate thumbnail width, and compare the remote capture’s viewport with the card’s CSS.
Every visit appears to make a new capture unique changes each time, or another request option varies. Keep the parameters stable for cache reuse and vary the cache key only for deliberate refreshes.
Locale or content differs from expectation The capture uses a different Accept-Language value, user agent, or target-side geo/session state. Set accept_languages or a suitable user_agent where supported; remember that your app’s visitor cookies are not automatically the target site’s session.
Your route can be used to request internal URLs It accepts arbitrary user-supplied destinations. Restrict allowed hosts or reject private and local address ranges, validate redirects and DNS resolution, and apply access controls and rate limits.

8. Or skip the browser setup

If you want screenshot cards without maintaining the signing and capture setup, ScreenshotNeo is a website screenshot API and MCP server. Its one-call API returns an image or PDF; here is the direct image request:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation. Its capture accepts cookie and consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and responses include X-Page-Verdict and X-Billed headers. An MCP server lets AI agents using Claude, Cursor, or another MCP client call take_screenshot, get_page_info, and capture_pdf. 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 to get 1,000 screenshots a month with no card.

9. FAQ

Does URL2PNG provide a Next.js package?

The reviewed documentation shows Node.js examples and a Node package, but no Next.js-specific package or recipe. The route above adapts the documented request format.

Should each card use a full-page screenshot?

Usually not for a compact preview. Viewport capture keeps the preview focused; full-page capture is useful when the entire page is the subject and a tall image is acceptable.

Can I put the URL2PNG secret in a server component?

Server-side code can read a private environment variable. Keep it out of client components, public variables, logs, and source control.

Where do I verify current URL2PNG pricing?

Use the URL2PNG plans page; plan values and terms can change.

Sources