Put the browser behind your Edge Function.

Send one public URL from trusted Deno code, receive the PNG bytes, and write them directly to Supabase Storage. The Latchshot key never reaches the browser; your application keeps ownership of users, authorization, object paths, retention, and delivery.

Deno server secret · Direct image bytes · Your Supabase bucket · Updated July 20, 2026

Keep each responsibility in one place.

Your Edge Function should authenticate the caller, confirm workspace access, request the capture, validate the response, and upload only successful image bytes. Latchshot performs the bounded public-page browser job and returns no hosted artifact URL.

Edge FunctionAuthenticate the user and authorize the verified workspace.
LatchshotRender the public page and return image/png bytes.
Supabase StorageCommit the bytes under an application-owned object path.
Authorization comes before capture.

The code below is the provider-and-storage core. Call it only after your existing Edge Function has verified the JWT and confirmed that the caller belongs to the supplied workspace or project. Never trust a client-provided storage prefix by itself.

Set the render key as a Supabase secret.

Create a recurring Free-plan key, then store it with the Edge Function secrets. Do not put it in a browser bundle, request URL, database row, or public repository.

Supabase CLIserver-side secret
supabase secrets set \
  LATCHSHOT_API_KEY='ls_live_replace_me'

Supabase supplies SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to deployed Edge Functions. The service-role key is equally sensitive: use it only after caller authorization, and never return it to the client.

Capture and store with one upstream request.

This helper uses the 1440×900 default viewport, captures the bounded full document, applies all five best-effort cleanup controls, validates the direct PNG response, and uploads it to a private website-reviews bucket.

Deno / TypeScriptcapture-and-store.ts
import { createClient } from "jsr:@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

type CaptureInput = {
  url: string;
  workspaceId: string; // use the ID verified by your authorization check
};

export async function captureAndStore(input: CaptureInput) {
  const target = new URL(input.url);
  if (target.protocol !== "https:" && target.protocol !== "http:") {
    throw new Error("The target must use http or https");
  }

  const response = await fetch("https://latchshot.fly.dev/v1/render", {
    method: "POST",
    headers: {
      authorization: `Bearer ${Deno.env.get("LATCHSHOT_API_KEY")}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      url: target.href,
      format: "png",
      width: 1440,
      height: 900,
      fullPage: true,
      waitUntil: "domcontentloaded",
      delay: 500,
      timeout: 30000,
      blockAds: true,
      blockTrackers: true,
      blockChats: true,
      hideCookieBanners: true,
      hidePopups: true,
    }),
  });

  if (!response.ok) {
    const code = response.headers.get("x-latchshot-error-code") ?? "render_failed";
    throw new Error(`Latchshot ${response.status}: ${code}`);
  }
  const contentType = response.headers.get("content-type")?.split(";", 1)[0];
  if (contentType !== "image/png") {
    throw new Error(`Unexpected render content type: ${contentType ?? "missing"}`);
  }

  const bytes = new Uint8Array(await response.arrayBuffer());
  const objectPath = `${input.workspaceId}/${crypto.randomUUID()}.png`;
  const { error } = await supabase.storage
    .from("website-reviews")
    .upload(objectPath, bytes, {
      contentType,
      cacheControl: "3600",
      upsert: false,
    });
  if (error) throw error;

  return {
    objectPath,
    bytes: bytes.byteLength,
    renderMs: response.headers.get("x-latchshot-render-ms"),
    navigation: response.headers.get("x-latchshot-navigation"),
    fonts: response.headers.get("x-latchshot-fonts"),
  };
}

Return the object path or application asset ID—not the service-role key or Latchshot key. Generate a short-lived signed Storage URL only when an authorized viewer needs the image, or use a public bucket only when public delivery is an intentional product decision.

Replace two provider fetches with one.

A common ScreenshotOne Edge Function asks for response_type=json, reads screenshot_url, then downloads that hosted URL before uploading the bytes to Supabase. Latchshot returns the artifact body directly, so the intermediate hosted URL and second provider request disappear.

Current behaviorLatchshot pathDecision
Query-string provider keyAuthorization: Bearer …Keep the credential out of logs and generated URLs.
response_type=json then screenshot_urlDirect image/png responseUpload the first successful response body.
Provider-hosted artifactYour Supabase bucketYour retention, access policy, and CDN stay authoritative.
full_page_algorithm=by_sectionsfullPage=true, optionally scrollPage=trueNo exact equivalent; bounded scrolling can activate lazy content, but compare representative long pages before cutover.
Ad/chat/cookie cleanupFive bounded cleanup booleansBest effort only; no custom filter or completeness promise.

Scroll the table horizontally to compare every behavior.

Keep the existing provider for pages that require sectional stitching, hosted artifact URLs, custom selectors or scripts, WebP/GIF, authenticated sessions, proxies, or anti-bot handling. Latchshot is not affiliated with ScreenshotOne or Supabase and is not a universal drop-in replacement.

Commit storage only after render success.

  • Render failsKeep the previous asset or deterministic placeholder. Failed Latchshot renders do not consume quota.
  • Transient statusRetry 429, 502, or 504 once with backoff; do not create an unbounded queue.
  • Upload failsRecord a retryable application error. The successful render consumed quota, but no asset row should claim storage succeeded.
  • Commit succeedsWrite the object path, capture time, byte count, and selected diagnostics to your asset record.

Use an idempotency record or deterministic job key when a queue can deliver the same job twice. Do not overwrite the last good object until the replacement upload succeeds.

Keep private state out of the browser job.

  • Only public HTTP/HTTPS pages on ports 80 and 443 are supported.
  • Private, loopback, link-local, special-use, credential-bearing, and mixed public/private DNS targets are rejected.
  • Full-page captures stop when the document exceeds 20,000 CSS pixels high or 5,000 wide.
  • Cookies, authenticated sessions, arbitrary page actions, CAPTCHA solving, proxies, and anti-bot bypass are not available.
  • Your application remains responsible for caller authorization, capture permission, Storage policies, retention, and deletion.

Use a browser you control when the page needs a login, a private preview URL, a click sequence, or application-specific state. Review the transient render and retention boundary before sending customer URLs.

Test the real full-page mix before buying capacity.

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 quotas are not a savings claim. Compare current provider spend, full-page success rate, long-page exclusions, retries, Supabase storage and egress, and the value of any features that remain on the stop list. Start with representative public pages—not a single easy homepage.

Fit first. Implementation if it fits.

The public migration-fit check is free. For an eligible public-repository JavaScript/TypeScript or Python call site, the optional $99 implementation pilot includes one 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.

Store one representative capture.

Start with a public page your product already needs, verify the full-page result and Storage policy, then test the longest and most dynamic pages before changing providers.

Get a free key