Keep Chromium out of your Vercel function.

Send one public URL from a trusted Next.js route, receive the PNG bytes, and return or store them. Your function keeps authorization and delivery; Latchshot owns the bounded browser process.

Node.js route · No browser package · Direct image bytes · Updated July 20, 2026

Move only the public-page browser job.

A serverless screenshot route often resolves or downloads a Chromium executable, launches it, opens a page, captures bytes, and closes the process inside one request. For a public URL and fixed output, the function can instead make one authenticated HTTPS request and keep the same response or storage contract.

Your callerUses your existing session, job token, or internal service credential.
Next.js routeAuthorizes the caller and sends the public URL with a server-only key.
LatchshotReturns bounded image/png bytes and render diagnostics.
Do not create an open screenshot proxy.

The sample uses a server-to-server token so the authorization boundary is visible. For a user-facing product, replace it with your existing session and workspace authorization. Never place either token in a NEXT_PUBLIC_ variable or browser bundle.

Store both credentials as server secrets.

Create a recurring Free-plan key, then add it and a separate internal route token to the Vercel environment. Enter the values interactively; do not put them in the command, deployment URL, or repository.

Vercel CLIproduction secrets
vercel env add LATCHSHOT_API_KEY production
vercel env add INTERNAL_SCREENSHOT_TOKEN production

Use distinct values. The internal token authorizes your route; the Latchshot key authenticates only the upstream render request. Rotate either one without changing the other.

Return the first validated response body.

This App Router handler accepts JSON, rejects unauthenticated callers, bounds its upstream navigation budget, validates the PNG response, and refuses artifacts above 15 MB before returning them. Adjust maxDuration to the limits of your Vercel plan and leave transfer headroom beyond the requested render timeout.

Next.js / TypeScriptapp/api/screenshot/route.ts
import { timingSafeEqual } from "node:crypto";

export const runtime = "nodejs";
export const maxDuration = 10;

const maxArtifactBytes = 15 * 1024 * 1024;

function matchesSecret(provided: string | null, expected: string) {
  if (!provided) return false;
  const left = Buffer.from(provided);
  const right = Buffer.from(expected);
  return left.length === right.length && timingSafeEqual(left, right);
}

export async function POST(request: Request) {
  const routeToken = process.env.INTERNAL_SCREENSHOT_TOKEN;
  const latchshotKey = process.env.LATCHSHOT_API_KEY;
  if (!routeToken || !latchshotKey) {
    return Response.json({ error: "server_not_configured" }, { status: 503 });
  }
  if (!matchesSecret(request.headers.get("x-internal-screenshot-token"), routeToken)) {
    return Response.json({ error: "unauthorized" }, { status: 401 });
  }

  let target: URL;
  try {
    const body = (await request.json()) as { url?: unknown };
    if (typeof body.url !== "string") throw new Error("missing url");
    target = new URL(body.url);
    if (target.protocol !== "https:" && target.protocol !== "http:") {
      throw new Error("unsupported protocol");
    }
  } catch {
    return Response.json({ error: "invalid_public_url" }, { status: 400 });
  }

  const upstream = await fetch("https://latchshot.fly.dev/v1/render", {
    method: "POST",
    headers: {
      authorization: `Bearer ${latchshotKey}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      url: target.href,
      format: "png",
      width: 1440,
      height: 900,
      waitUntil: "domcontentloaded",
      delay: 250,
      timeout: 7000,
    }),
    cache: "no-store",
  });

  if (!upstream.ok) {
    const code = upstream.headers.get("x-latchshot-error-code") ?? "render_failed";
    await upstream.body?.cancel();
    return Response.json(
      { error: "capture_failed", code, retryable: [429, 502, 504].includes(upstream.status) },
      { status: upstream.status === 429 ? 429 : 502 },
    );
  }

  const contentType = upstream.headers.get("content-type")?.split(";", 1)[0];
  const declaredBytes = Number(upstream.headers.get("content-length") ?? 0);
  if (contentType !== "image/png" || declaredBytes > maxArtifactBytes) {
    await upstream.body?.cancel();
    return Response.json({ error: "invalid_artifact" }, { status: 502 });
  }

  const bytes = await upstream.arrayBuffer();
  if (bytes.byteLength > maxArtifactBytes) {
    return Response.json({ error: "artifact_too_large" }, { status: 502 });
  }

  return new Response(bytes, {
    headers: {
      "content-type": contentType,
      "cache-control": "no-store",
      "x-render-ms": upstream.headers.get("x-latchshot-render-ms") ?? "",
      "x-navigation": upstream.headers.get("x-latchshot-navigation") ?? "",
    },
  });
}

Call this route only from trusted server code. If a browser must invoke it, replace the internal header with your application's authenticated session, CSRF protection, tenant authorization, and rate limit.

Test the route without exposing either key.

Use the internal token from a protected shell or calling service. The request URL contains no credential and the response body is the PNG itself.

Server-to-server checkdirect PNG
curl --fail-with-body https://your-app.vercel.app/api/screenshot \
  -X POST \
  -H "x-internal-screenshot-token: $INTERNAL_SCREENSHOT_TOKEN" \
  -H "content-type: application/json" \
  --data '{"url":"https://example.com"}' \
  --output capture.png

If your application needs durable delivery, upload the validated bytes to storage before returning an asset ID or signed URL. The Supabase storage guide shows the same commit-after-success boundary.

Keep Playwright for stateful browser work.

Current browser responsibilityHosted public-page pathDecision
Bundle or runtime-download ChromiumOne HTTPS render requestMove when the browser exists only to produce public-page bytes.
Launch and close a process per function callLatchshot owns browser isolation and queueingYour route still owns caller authorization and time budget.
Fixed 1440×900 PNGDirect image/png bodyPreserve the existing response or storage contract.
Authenticated page, cookies, or private previewNo equivalentKeep Playwright inside the authorized network.
Clicks, selectors, scripts, stealth patches, or CAPTCHA handlingNo equivalentKeep the controlled browser or current provider.
Public page exceeds the function budgetLower the render timeout or move the call to a queueDo not assume an external browser removes the caller's own duration limit.

Scroll the table horizontally to compare every responsibility.

Latchshot supports public HTTP/HTTPS targets on ports 80 and 443, bounded viewports, PNG/JPEG/PDF, and documents up to 20,000 CSS pixels high. It does not provide private-network access, sessions, arbitrary scripts, proxy rotation, anti-bot bypass, or hosted artifact URLs.

Keep retries outside the user request when possible.

  • Invalid targetReturn a stable 400 from your route. Latchshot independently rejects private, loopback, credential-bearing, special-use, and mixed-DNS targets.
  • Transient upstreamRetry 429, 502, or 504 once with backoff in a job queue. Avoid holding a browser-facing request open through repeated attempts.
  • Function deadlineLower the requested render timeout or move capture to a background worker whose deadline fits. Do not extend the route beyond your deployed plan's limit.
  • Last good assetKeep serving the prior stored capture until the replacement render and storage write both succeed.

Failed direct renders do not consume successful-render quota. A render that succeeds but cannot be delivered or stored has still consumed quota, so validate and commit the response once.

Compare the whole serverless path.

Free includes 100 successful renders per UTC calendar month. Launch is $19 for 2,500, Build is $49 for 12,000, and Scale is $149 for 50,000. Automatic overages are disabled.

Those prices are not proof that an API is cheaper than Chromium in your function. Compare function duration, executable download and cache behavior, memory, cold starts, retries, image transfer, storage, target fit, and the engineering time needed to maintain the browser path.

Fit first. Implementation if it fits.

The public migration-fit check is free. For one eligible public-repository Next.js or TypeScript call site, the optional $99 implementation pilot includes a focused patch, tests, an approved public sample, and a rollback note. API usage is separate. Never include a key, private or signed URL, customer data, or sensitive artifact in the issue.

Vercel and Next.js are referenced for interoperability. Latchshot is not affiliated with or endorsed by Vercel.

Remove one browser launch.

Start with one fixed-viewport public page, measure the function duration and returned artifact, then test the slowest representative pages before changing the production path.

Get a free key