The Deno screenshot API from ScreenshotRun captures any webpage with a single fetch call. No npm packages to install, no Puppeteer binary to download, no node_modules folder. Deno ships with fetch built in, so calling a REST screenshot endpoint is five lines of code with zero dependencies.
Deno developers who need screenshots usually look at astral or try running Puppeteer through the npm compatibility layer. Both approaches pull in a Chromium binary. On Deno Deploy that is a dead end because the platform does not allow binary execution. Even on a local server, managing Chrome versions across Deno updates gets old fast. A fetch call to a screenshot API sidesteps all of it.
First Deno screenshot in 30 seconds
Open the dashboard and register. Your API key appears on the account settings page. The starter plan covers 200 captures per month at no cost.
Save this as screenshot.ts and run it with deno run --allow-net --allow-write screenshot.ts:
const response = await fetch(
"https://api.screenshotrun.com/v1/screenshots/capture?" +
new URLSearchParams({
url: "https://github.com",
format: "png",
response_type: "image",
}),
{
headers: { Authorization: "Bearer YOUR_API_KEY" },
}
);
const bytes = new Uint8Array(await response.arrayBuffer());
await Deno.writeFile("github.png", bytes);
console.log(`Saved: github.png (${Math.round(bytes.length / 1024)} KB)`);
No package.json, no install step. Deno's permission flags (--allow-net for the API call, --allow-write for saving the file) are the only setup. Our servers render the page in a headless Chrome instance, including JavaScript and lazy-loaded images, then send the raw bytes back.
Reusable screenshot helper for Deno
A typed function you can import from any module in your project:
interface ScreenshotOptions {
url: string;
format?: "png" | "jpeg" | "webp" | "avif" | "pdf";
width?: number;
height?: number;
fullPage?: boolean;
}
async function captureScreenshot(
apiKey: string,
options: ScreenshotOptions
): Promise<Uint8Array> {
const params = new URLSearchParams({
url: options.url,
format: options.format ?? "png",
width: String(options.width ?? 1280),
full_page: String(options.fullPage ?? false),
response_type: "image",
});
if (options.height) {
params.set("height", String(options.height));
}
const resp = await fetch(
`https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
if (!resp.ok) {
throw new Error(`Screenshot failed: ${resp.status} ${resp.statusText}`);
}
return new Uint8Array(await resp.arrayBuffer());
}
Call it like this:
const apiKey = Deno.env.get("SCREENSHOTRUN_KEY")!;
const screenshot = await captureScreenshot(apiKey, {
url: "https://github.com",
format: "webp",
width: 1920,
fullPage: true,
});
await Deno.writeFile("github-full.webp", screenshot);
console.log("Captured full-page WebP screenshot");
The API key comes from Deno.env.get(), which requires the --allow-env flag. TypeScript types catch parameter mistakes at compile time, before the request ever fires.
Screenshot proxy with Deno.serve
Deno 2.x includes Deno.serve as the built-in HTTP server. You can expose a screenshot endpoint that keeps the API key on the server while the frontend gets images directly:
const API_KEY = Deno.env.get("SCREENSHOTRUN_KEY")!;
Deno.serve({ port: 8000 }, async (req: Request) => {
const url = new URL(req.url);
if (url.pathname !== "/api/screenshot") {
return new Response("Not found", { status: 404 });
}
const targetUrl = url.searchParams.get("url");
if (!targetUrl) {
return new Response("Missing url parameter", { status: 400 });
}
const params = new URLSearchParams({
url: targetUrl,
format: url.searchParams.get("format") ?? "png",
response_type: "image",
});
const width = url.searchParams.get("width");
if (width) params.set("width", width);
const apiResp = await fetch(
`https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
);
if (!apiResp.ok) {
return new Response("Screenshot failed", { status: apiResp.status });
}
return new Response(apiResp.body, {
headers: {
"Content-Type": apiResp.headers.get("Content-Type") ?? "image/png",
"Cache-Control": "public, max-age=3600",
},
});
});
Run it with deno run --allow-net --allow-env server.ts. The frontend hits /api/screenshot?url=https://example.com&format=png and gets the image. The body streams through without buffering the entire response in memory first.
Fresh framework integration
If you build with Fresh (Deno's full-stack web framework), add a route handler that proxies screenshot requests:
// routes/api/screenshot.ts
import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
async GET(req) {
const url = new URL(req.url);
const targetUrl = url.searchParams.get("url");
if (!targetUrl) {
return new Response("Missing url", { status: 400 });
}
const params = new URLSearchParams({
url: targetUrl,
format: url.searchParams.get("format") ?? "png",
width: url.searchParams.get("width") ?? "1280",
response_type: "image",
});
const resp = await fetch(
`https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
{
headers: {
Authorization: `Bearer ${Deno.env.get("SCREENSHOTRUN_KEY")}`,
},
}
);
if (!resp.ok) {
return new Response("Capture failed", { status: resp.status });
}
return new Response(resp.body, {
headers: { "Content-Type": resp.headers.get("Content-Type") ?? "image/png" },
});
},
};
Fresh routes map to file paths, so this handler automatically registers at /api/screenshot. Deploying to Deno Deploy works without changes because there is no Chromium dependency to bundle.
Available parameters
Every option goes into the URLSearchParams object. These are the ones Deno projects use most:
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | required | Page address to capture |
format | string | png | Output type: png, jpeg, webp, avif, pdf |
width | number | 1280 | Viewport width in pixels |
height | number | 720 | Viewport height in pixels |
full_page | boolean | false | Scroll the page and capture everything |
device | string | desktop | Viewport preset: desktop, mobile, tablet |
retina | boolean | false | Render at 2x pixel density |
dark_mode | boolean | false | Activate prefers-color-scheme: dark |
selector | string | CSS selector to isolate one element | |
delay | number | 0 | Wait N ms before the capture |
block_cookies | boolean | false | Auto-dismiss cookie popups |
block_ads | boolean | false | Filter out ad placements |
block_chats | boolean | false | Remove chat widget overlays |
css | string | Inject a stylesheet before rendering | |
js | string | Run a script before the capture |
The API documentation covers every parameter with request and response examples.
Dark mode, element capture, and CSS injection
Dark mode
Pass dark_mode=true in the query. The renderer activates the dark color scheme media query, so sites with a dark theme display it without extra work:
const params = new URLSearchParams({
url: "https://github.com",
dark_mode: "true",
response_type: "image",
});
const resp = await fetch(
`https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
Element capture
Supply a CSS selector through the selector parameter to clip output to a single DOM element:
const params = new URLSearchParams({
url: "https://github.com/denoland",
selector: ".js-repo-list",
response_type: "image",
});
CSS injection
Hide a promotional banner or swap fonts before the capture runs:
const params = new URLSearchParams({
url: "https://example.com",
css: ".promo-banner { display: none !important; }",
response_type: "image",
});
All three fit into one URLSearchParams object. A single fetch handles dark theme, element cropping, and injected CSS in one round trip.
Retry logic for production Deno apps
Network calls fail. Rate limits kick in, target sites time out, servers occasionally return 5xx. Production code should handle retries:
async function captureWithRetry(
apiKey: string,
url: string,
maxRetries = 2
): Promise<Uint8Array> {
const params = new URLSearchParams({
url,
format: "png",
response_type: "image",
});
const endpoint = `https://api.screenshotrun.com/v1/screenshots/capture?${params}`;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const resp = await fetch(endpoint, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (resp.ok) {
return new Uint8Array(await resp.arrayBuffer());
}
if (resp.status === 429) {
const wait = Number(resp.headers.get("Retry-After") ?? "5");
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (resp.status >= 500 && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, (attempt + 1) * 1000));
continue;
}
throw new Error(`Screenshot failed: HTTP ${resp.status}`);
}
throw new Error("Max retries exceeded");
}
A 429 status means you've hit the rate ceiling. The function reads Retry-After and pauses for that duration. For 5xx responses it waits progressively longer between attempts. If the status points to a client mistake (wrong key, missing URL), it throws immediately since re-sending the same request changes nothing.
Why fetch beats Puppeteer and astral in Deno
Puppeteer (via npm compatibility) and astral give you a real browser you can drive: click buttons, fill forms, run scripts across pages. If the task is just turning a URL into an image, they add weight you don't need.
| Self-hosted (Puppeteer / astral) | ScreenshotRun API | |
|---|---|---|
| Dependencies | Chromium binary (~300 MB download) | Zero. fetch is built into Deno |
| Deno Deploy | Not possible (no binary execution) | Works out of the box |
| Memory usage | 200-500 MB per browser tab | Nothing on your server |
| Permissions | --allow-run --allow-read --allow-write --allow-env --allow-net | --allow-net only (plus --allow-write to save) |
| TypeScript | Extra type packages or manual typing | URLSearchParams, standard Response types |
| Cookie banners | Custom dismiss scripts you maintain | One parameter: block_cookies=true |
| Cold start | 2-5 seconds to launch Chrome | No startup cost on your side |
| Pricing | Server CPU/RAM for each browser instance | 200 free captures/month, then usage-based |
When you need a full browser for login flows or multi-page scraping, Puppeteer or astral is the right pick. When the job is converting URLs to images on Deno Deploy or any Deno server, fetch gets you there with less code and no Chromium baggage.
Deno teams commonly use this for scheduled screenshot jobs, website change detection, and generating dashboard snapshots in cron tasks.
FAQ#
Yes. Call the ScreenshotRun API with the built-in fetch function. Pass the target URL and format as query parameters, get image bytes back. No Chromium binary, no npm packages, no node_modules folder.
It does. Deno Deploy blocks binary execution, so Puppeteer and astral cannot run there. A fetch call to an external API is the only way to generate screenshots on that platform.
At minimum --allow-net to make the HTTP request. Add --allow-env if you read the API key from an environment variable, and --allow-write if you save the image to disk.
Create a file at routes/api/screenshot.ts that exports a GET handler. The handler reads query parameters from the request, forwards them to the ScreenshotRun API with your key, and streams the response body back to the client.
PNG, JPEG, WebP, AVIF, and PDF. Set the format parameter in your URLSearchParams. The response comes back as raw bytes you can write with Deno.writeFile or stream to a client.
For URL-to-image conversion, yes. Puppeteer needs 2-5 seconds just to launch Chrome plus the rendering time. A fetch call to the API skips browser startup entirely and typically returns in 2-4 seconds total.