Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

To stream a wkhtmltoimage result from Next.js without buffering the entire image, launch the renderer with Node.js spawn(), pipe its stdout into the route response, and keep stderr and process errors separate. In the App Router, return a Web ReadableStream from a Node.js Route Handler. In the Pages Router, write chunks to res and finish with res.end(). The executable must be present in the deployment image, and your proxy or hosting platform must not buffer the response.

What the streaming design looks like

The request enters a Next.js API endpoint, the endpoint validates its input, and Node starts wkhtmltoimage as a child process. With stdio: ['ignore', 'pipe', 'pipe'], the child exposes separate readable streams for image bytes (stdout) and diagnostics (stderr). The route forwards stdout as it arrives instead of first assembling a large Buffer.

  • Use the Node.js runtime, not an edge runtime: subprocess APIs such as child_process.spawn() require Node and an executable binary.
  • Pass the executable and arguments as separate values. Do not interpolate a URL or HTML into a shell command.
  • Confirm the exact binary’s output contract. Some builds write an image to a file for the output argument rather than stdout.
  • Set the image content type to match the format you actually request and produce.
  • Stop the child if the client disconnects, and enforce input, time, and concurrency limits.

The examples below assume a build that can write the selected image format to stdout. Run a local probe and repeat it in the production container before relying on that assumption.

App Router: a streaming Route Handler

Create app/api/image/route.ts. Route Handlers use the Web Request and Response APIs and can return a response whose body is a Web stream (Next.js Route Handler reference).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Complete TypeScript example

import { spawn } from 'node:child_process';
import { once } from 'node:events';

export const runtime = 'nodejs';

const MAX_URL_LENGTH = 2_048;
const MAX_RENDER_MS = 45_000;
const executable = process.env.WKHTMLTOIMAGE_BIN || 'wkhtmltoimage';

function validHttpUrl(value: string): URL | null {
  if (value.length === 0 || value.length > MAX_URL_LENGTH) return null;
  try {
    const url = new URL(value);
    return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
  } catch {
    return null;
  }
}

export async function GET(request: Request) {
  const requested = new URL(request.url).searchParams.get('url');
  if (!requested) {
    return Response.json({ error: 'Missing url parameter' }, { status: 400 });
  }
  const target = validHttpUrl(requested);
  if (!target) {
    return Response.json({ error: 'url must be an http(s) URL no longer than 2048 characters' }, { status: 400 });
  }

  // The final argument is '-' for binaries that support stdout output.
  // Verify this behavior with the binary installed in your image.
  const child = spawn(executable, [
    '--quiet',
    '--format', 'webp',
    target.toString(),
    '-'
  ], { stdio: ['ignore', 'pipe', 'pipe'] });

  let stderr = '';
  const stderrLimit = 16_384;
  child.stderr.setEncoding('utf8');
  child.stderr.on('data', (chunk: string) => {
    if (stderr.length < stderrLimit) stderr += chunk.slice(0, stderrLimit - stderr.length);
  });

  let timer: NodeJS.Timeout;
  const body = new ReadableStream<Uint8Array>({
    start(controller) {
      child.stdout.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
      child.stdout.on('end', () => controller.close());
      child.stdout.on('error', (error) => controller.error(error));
      child.once('error', (error) => controller.error(error));

      timer = setTimeout(() => {
        child.kill('SIGKILL');
        controller.error(new Error('wkhtmltoimage timed out'));
      }, MAX_RENDER_MS);
    },
    cancel() {
      clearTimeout(timer);
      if (!child.killed) child.kill('SIGTERM');
    }
  });

  // A nonzero exit can occur after headers and image chunks have been sent;
  // log it and use monitoring to detect incomplete responses.
  child.once('close', (code, signal) => {
    clearTimeout(timer);
    if (code !== 0) {
      console.error('wkhtmltoimage failed', { code, signal, stderr });
    }
  });

  return new Response(body, {
    headers: {
      'Content-Type': 'image/webp',
      'Content-Disposition': 'inline; filename="capture.webp"',
      'Cache-Control': 'no-store',
      'X-Content-Type-Options': 'nosniff'
    }
  });
}

The stream’s cancel() method is important: when a browser closes the connection, the Web stream can terminate the renderer rather than leaving an orphaned process. The timeout protects the server from a page that never finishes loading. In a production implementation, add an application-wide semaphore or queue so an attacker cannot start unlimited renderers.

Do not send misleading success responses

Once the first bytes are sent, HTTP status and headers cannot be changed. A renderer failure discovered later can therefore produce a truncated image with a successful status. Validate the request before spawning, keep stderr bounded, and record exit status and byte counts. If you need an error status, render to a temporary file first, verify a zero exit code, then stream the verified file; that approach uses disk and delays the first byte but gives you a reliable status decision.

Pages Router: stream through res

For pages/api/image.ts, the documented pattern is res.writeHead(), repeated res.write(chunk), and res.end() (Next.js API Routes).

Rank #2
Sale
HTML and CSS: Design and Build Websites
  • HTML CSS Design and Build Web Sites
  • Comes with secure packaging
  • It can be a gift option
import type { NextApiRequest, NextApiResponse } from 'next';
import { spawn } from 'node:child_process';

export const config = { api: { responseLimit: false } };

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    res.setHeader('Allow', 'GET');
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const value = typeof req.query.url === 'string' ? req.query.url : '';
  let target: URL;
  try {
    target = new URL(value);
    if (!['http:', 'https:'].includes(target.protocol) || value.length > 2048) throw new Error();
  } catch {
    return res.status(400).json({ error: 'Invalid url' });
  }

  const child = spawn(process.env.WKHTMLTOIMAGE_BIN || 'wkhtmltoimage', [
    '--quiet', '--format', 'png', target.toString(), '-'
  ], { stdio: ['ignore', 'pipe', 'pipe'] });

  let finished = false;
  child.stderr.on('data', chunk => console.error('wkhtmltoimage:', chunk.toString()));
  child.on('error', error => {
    if (!finished) res.destroy(error);
  });
  child.on('close', code => {
    finished = true;
    if (code !== 0 && !res.writableEnded) res.destroy(new Error(`renderer exited ${code}`));
    else if (!res.writableEnded) res.end();
  });

  res.writeHead(200, {
    'Content-Type': 'image/png',
    'Content-Disposition': 'inline; filename="capture.png"',
    'Cache-Control': 'no-store',
    'X-Content-Type-Options': 'nosniff'
  });

  child.stdout.on('data', chunk => {
    if (!res.write(chunk)) child.stdout.pause();
  });
  res.on('drain', () => child.stdout.resume());
  res.on('close', () => {
    if (!finished) child.kill('SIGTERM');
  });
}

Because headers are written before the process finishes, this version has the same late-failure limitation as the Route Handler. For strict error reporting, use a temporary output file, check the exit code, and only then pipe the file.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Verify wkhtmltoimage’s output behavior

The Debian Bookworm manual describes command-line inputs and output options (wkhtmltoimage(1)), while distributions and builds can differ in Qt libraries, fonts, patched features, and stdout handling. The project site describes the renderer and its distribution approach (wkhtmltopdf.org).

  1. Inside the same operating-system image used in deployment, run wkhtmltoimage --version.
  2. Capture a tiny public page to a file and inspect it: wkhtmltoimage --format png https://example.com /tmp/test.png.
  3. Test the stdout form your code uses, for example wkhtmltoimage --format png https://example.com - > /tmp/stdout.png.
  4. Check that the output opens as an image and that the process exits with code zero.
  5. If stdout is unsupported or unreliable, create a unique temporary filename, render there, open it as a bounded read stream, and delete it in a finally block after the stream closes.

Never accept arbitrary local paths from a request. Restrict URLs to the schemes and hosts your application intends to fetch, and consider network egress controls to reduce server-side request forgery risk. Renderer switches that enable local-file access should be treated as privileged deployment settings, not user input.

Streaming through proxies and hosting platforms

A Web Response or repeated res.write() calls do not guarantee that a client sees progressive bytes. Reverse proxies, CDNs, load balancers, and managed platforms may buffer until the process finishes. Next.js’s self-hosting guidance discusses proxy buffering and gives nginx’s X-Accel-Buffering: no as a configuration example (Next.js self-hosting). Platform deployment guidance also requires a streaming-capable path (Deploying to Platforms).

  • Test through the public production URL, not only localhost.
  • Inspect time-to-first-byte and whether the client receives multiple chunks.
  • Configure the reverse proxy to disable response buffering for this route where supported.
  • Check platform execution time, memory, temporary-disk, and child-process restrictions before choosing this architecture.
  • Keep renderer concurrency below the memory and CPU capacity of the host.

Input, reliability, and performance controls

Validate and bound requests

Impose a maximum URL or HTML size, allow only expected HTTP schemes, and reject unsupported query options. If you accept HTML, do not place it in a shell string; pass it through a documented stdin mode or a temporary file with restrictive permissions. Avoid unrestricted file URLs.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Control process lifetime

Set a deadline shorter than the platform’s request timeout. Kill the child on timeout and client disconnect, and reap it by awaiting its close event. Keep stderr capped so a failing page cannot consume unbounded memory.

Rank #4
Sale
Web Design with HTML, CSS, JavaScript and jQuery Set
  • Brand: Wiley
  • Set of 2 Volumes
  • A handy two-book set that uniquely combines related technologies Highly visual format and accessible language makes these books highly effective learning tools Perfect for beginning web designers and front-end developers

Use backpressure

In the Pages Router, pause stdout when res.write() returns false and resume on drain. The Web-stream adapter should enqueue only as the consumer pulls; for high-throughput services, use a carefully tested adapter that respects the Node-to-Web stream boundary rather than buffering all chunks.

Cache deliberately

Images can be large and rendering is CPU-intensive. If URLs and rendering options are repeatable, cache by a normalized request key, set an explicit freshness policy, and avoid caching private pages. Do not claim a cache hit is a successful render unless your application records that distinction.

Common failures and fixes

Symptom Likely cause Fix
ENOENT on spawn Binary is absent or not on PATH. Install it in the runtime image, set WKHTMLTOIMAGE_BIN to an absolute path, and verify execute permissions.
Zero-byte or corrupt image This build writes to a file, not stdout; or the process failed after headers. Run the stdout probe, inspect exit code and stderr, or render to a temporary file before streaming.
Fonts or images missing Runtime image lacks fonts, certificates, or network access. Install required fonts and CA certificates, and test from the production image.
Response arrives only at the end Proxy or platform buffering. Disable buffering where supported and test the public route for chunked delivery.
Requests hang until platform timeout Page load never settles or child is not killed. Add a render deadline, kill on timeout, and terminate on response close.
Renderer exits nonzero Invalid URL, unreachable page, unsupported option, or missing shared library. Log bounded stderr, reproduce in the deployment image, and return a controlled error when headers have not been sent.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or a PDF, so your Next.js route does not need to package wkhtmltoimage or manage a child process. Before capture it accepts cookie or consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use the API from your server (keep the key private). The parameter names used by other screenshot APIs also work, which can simplify migration. See the ScreenshotNeo documentation for all options, including full-page and element capture, device and viewport settings, JavaScript, custom headers and cookies, blocking rules, PDF controls, signed links, asynchronous jobs, bulk capture, and usage reporting.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());

ScreenshotNeo includes an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Can an Edge Route Handler run wkhtmltoimage?

No. This design depends on Node’s child-process APIs and an installed executable, so select the Node.js runtime and a host that permits both.

Should I use exec() instead?

No for image streaming. Synchronous child-process methods block the event loop, and buffered execution defeats the purpose of sending bytes as they are produced. Use spawn() with piped stdout.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can I change the status code after rendering fails?

Only if you have not sent headers. Once streaming begins, use a temporary-file or preflight design when an accurate failure status is mandatory.

Frequently Asked Questions

Does streaming eliminate memory use?

It avoids storing the complete image in a Node Buffer, but the renderer, stream queues, proxy buffers, and the client still consume memory. Set limits and measure the deployed path.

What if my wkhtmltoimage package cannot write to stdout?

Render to a unique temporary file, verify the exit code, then create a bounded read stream and remove the file after completion. Confirm the behavior of the exact binary in your deployment image.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.