How to Take a Screenshot of a Website in SvelteKit
Build a SvelteKit screenshot endpoint with Playwright, return PNG or WebP bytes, handle full-page captures, and troubleshoot deployment issues.
Use Playwright in a server-side SvelteKit +server.js or +server.ts route. Validate the target URL, launch a browser, navigate with an explicit timeout and readiness policy, capture the page, and return the image bytes with the correct Content-Type. Keep Playwright imports and browser launches on the server so browser binaries and credentials never enter the client bundle.
This guide builds a complete endpoint, then covers viewport, full-page, element, format, scale, masking, readiness, deployment, reliability, performance, cost, and troubleshooting.
1. Install Playwright
In an existing SvelteKit project, install Playwright and its browser binaries:
npm install playwright
npx playwright install chromium
Your deployment must run a server-capable SvelteKit adapter. A static-only deployment cannot execute a server-side browser process. SvelteKit lists adapters for Node, Cloudflare, Netlify, static hosting, and Vercel; adapter-node builds a standalone Node server. Confirm that your host permits the browser binary, child processes, memory, execution time, and outbound network access.
2. Create a basic screenshot endpoint
Create src/routes/api/screenshot/+server.js:
import { chromium } from 'playwright';
import { error } from '@sveltejs/kit';
export async function GET({ url }) {
const target = url.searchParams.get('url');
if (!target) throw error(400, 'Missing url');
let parsed;
try {
parsed = new URL(target);
} catch {
throw error(400, 'Invalid url');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw error(400, 'Only http and https URLs are supported');
}
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 }
});
await page.goto(target, {
waitUntil: 'networkidle',
timeout: 30_000
});
const image = await page.screenshot({
fullPage: true,
type: 'png'
});
return new Response(image, {
headers: {
'content-type': 'image/png',
'cache-control': 'no-store'
}
});
} finally {
await browser.close();
}
}
Run the development server and request:
npm run dev
curl -G 'http://localhost:5173/api/screenshot' \
--data-urlencode 'url=https://example.com' \
-o example.png
The endpoint returns PNG bytes directly, so it can be used by an image tag, an upload worker, or another API client. Playwright documents viewport, element, and full-scrollable-page capture in its screenshot guide.
3. Choose the capture scope
Viewport screenshot
Omit fullPage to capture only the visible viewport:
const image = await page.screenshot({ type: 'png' });
Full-page screenshot
Set fullPage: true to capture the complete scrollable page:
const image = await page.screenshot({
fullPage: true,
type: 'png'
});
Very tall pages can consume substantial memory. Consider clipping the capture, splitting long documents, or using an asynchronous job for large pages.
Element screenshot
Capture one element by locator. The locator waits for the element and captures its bounding box:
const card = page.locator('.pricing-card').first();
await card.waitFor({ state: 'visible', timeout: 10_000 });
const image = await card.screenshot({ type: 'png' });
For a user-supplied selector, validate or constrain it before passing it to Playwright. A malformed selector should become a controlled 400 response rather than an unhandled server error.
4. Control output format and resolution
Playwright can return PNG, JPEG, or WebP buffers. The screenshot type can also be inferred from a file extension when writing to disk, as described in the API reference.
const png = await page.screenshot({ type: 'png' });
const jpeg = await page.screenshot({ type: 'jpeg', quality: 82 });
const webp = await page.screenshot({ type: 'webp', quality: 82 });
JPEG and WebP accept quality controls; PNG is lossless and does not use JPEG quality. Return a matching content type:
return new Response(webp, {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=300'
}
});
Use the viewport to set CSS dimensions. Use scale: 'css' for one output pixel per CSS pixel or scale: 'device' for device-pixel output:
const page = await browser.newPage({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 1
});
const image = await page.screenshot({
fullPage: true,
scale: 'css',
type: 'png'
});
5. Make page readiness deterministic
waitUntil: 'networkidle' is useful for many pages, but pages with analytics, polling, WebSockets, or advertisements may never become idle. Choose the readiness rule that matches the target.
domcontentloaded: fast; HTML is parsed, but images and application data may still be loading.load: page resources have fired the load event.networkidle: useful for mostly static pages, but can wait indefinitely on long-lived connections.- Selector wait: best when the application has a reliable rendered-state marker.
- Fixed delay: a fallback for animations or delayed widgets; keep it bounded.
await page.goto(target, {
waitUntil: 'domcontentloaded',
timeout: 30_000
});
await page.locator('[data-screenshot-ready]').waitFor({
state: 'visible',
timeout: 15_000
});
If no marker exists, use a short bounded delay:
await page.waitForTimeout(1_000);
Do not rely on a single readiness policy for every site. A route that accepts arbitrary URLs should expose a small allowlist of policies and enforce a maximum timeout.
6. Stabilize and protect captures
Disable animation
await page.addStyleTag({
content: `*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}`
});
Mask sensitive or variable regions
const image = await page.screenshot({
fullPage: true,
mask: [
page.locator('.account-email'),
page.locator('[data-private]')
],
maskColor: '#808080',
type: 'png'
});
Set headers, cookies, and authentication
const context = await browser.newContext({
extraHTTPHeaders: { 'Accept-Language': 'en-US' },
userAgent: 'ScreenshotWorker/1.0'
});
await context.addCookies([
{
name: 'session',
value: process.env.SESSION_COOKIE,
domain: 'example.com',
path: '/'
}
]);
const page = await context.newPage();
Never accept arbitrary request headers or cookies from an unauthenticated caller. They can expose credentials or turn your endpoint into a proxy.
7. A production-ready route pattern
This version validates the URL, limits the timeout, supports a format parameter, waits for an optional selector, and converts browser failures into useful HTTP responses:
import { chromium } from 'playwright';
import { error } from '@sveltejs/kit';
const formats = new Set(['png', 'jpeg', 'webp']);
export async function GET({ url }) {
const target = url.searchParams.get('url');
const readySelector = url.searchParams.get('ready');
const requestedFormat = url.searchParams.get('format') ?? 'png';
if (!target) throw error(400, 'Missing url');
if (!formats.has(requestedFormat)) throw error(400, 'Unsupported format');
let parsed;
try {
parsed = new URL(target);
} catch {
throw error(400, 'Invalid url');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw error(400, 'Only http and https URLs are supported');
}
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 }
});
await page.goto(target, {
waitUntil: 'domcontentloaded',
timeout: 30_000
});
if (readySelector) {
await page.locator(readySelector).waitFor({
state: 'visible',
timeout: 15_000
});
} else {
await page.waitForLoadState('load', { timeout: 15_000 }).catch(() => {});
}
await page.addStyleTag({
content: `*, *::before, *::after {
animation: none !important;
transition: none !important;
}`
});
const image = await page.screenshot({
fullPage: true,
type: requestedFormat,
...(requestedFormat === 'png' ? {} : { quality: 82 })
});
const contentType = requestedFormat === 'png'
? 'image/png'
: requestedFormat === 'jpeg'
? 'image/jpeg'
: 'image/webp';
return new Response(image, {
headers: {
'content-type': contentType,
'cache-control': 'no-store'
}
});
} catch (cause) {
console.error('Screenshot failed', cause);
throw error(502, 'The target page could not be captured');
} finally {
await browser.close();
}
}
8. Return, cache, or store the bytes
page.screenshot() returns a buffer when no path is supplied. Returning it directly avoids temporary files. If a screenshot is deterministic for a URL and option set, cache it using a key containing the URL, viewport, format, scale, readiness policy, and relevant authentication state.
Use cache-control: no-store for private pages. For public, versioned pages, a bounded max-age can reduce repeated browser work. If you write files, use an object store or temporary directory and delete failures in a finally block.
9. Deploy SvelteKit with a browser
- Choose a server adapter that can run Node or the provider’s supported browser runtime.
- Install Chromium during the build or use a host-provided browser binary.
- Set the executable path only when your host requires it; otherwise let Playwright manage its installed browser.
- Set memory and execution limits high enough for Chromium and the target page.
- Limit concurrency. A browser context per request is safer than launching unlimited browsers.
- Allow outbound HTTPS traffic to permitted target hosts.
Serverless cold starts increase latency because the browser process and binaries may need initialization. A warm worker, a browser pool, bounded concurrency, and caching reduce that overhead. The correct limits depend on the hosting provider and page being captured.
10. Security checklist for URL screenshot endpoints
- Allow only
httpandhttps. - Block private network ranges and cloud metadata addresses if callers can submit arbitrary URLs.
- Require authentication and rate limits.
- Restrict maximum navigation time, page size, screenshot dimensions, and concurrent jobs.
- Do not forward caller-controlled cookies, Authorization headers, or user agents without a policy.
- Keep browser credentials and Playwright imports in server-only modules.
- Do not return detailed internal browser errors to untrusted callers.
11. cURL, Python, and Node.js clients
cURL
curl -G 'http://localhost:5173/api/screenshot' \
--data-urlencode 'url=https://example.com' \
--data-urlencode 'format=webp' \
-o example.webp
Python
import requests
r = requests.get(
'http://localhost:5173/api/screenshot',
params={'url': 'https://example.com', 'format': 'png'},
timeout=90,
)
r.raise_for_status()
with open('example.png', 'wb') as output:
output.write(r.content)
Node.js
const q = new URLSearchParams({
url: 'https://example.com',
format: 'webp'
});
const res = await fetch(`http://localhost:5173/api/screenshot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('example.webp', bytes));
12. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Executable doesn't exist |
Chromium was not installed in the build environment. | Run npx playwright install chromium during image or build creation, or configure the host browser path. |
| Navigation timeout | The page is slow, blocked, or maintains long-lived requests. | Use a bounded timeout, switch from networkidle to domcontentloaded, then wait for a specific selector. |
| Blank or incomplete image | Client-side rendering or lazy content has not finished. | Wait for the rendered selector, a bounded delay, or the relevant network response before capture. |
| Element not found | Selector is wrong, the element is inside a frame, or it is rendered conditionally. | Check the selector, wait for visibility, and use the correct frame locator when needed. |
| Fonts or images differ | Resources are blocked, cross-origin requests fail, or capture occurs before loading. | Check outbound access, wait for the required resources, and inspect browser console/network errors. |
| Memory exhaustion | Many concurrent Chromium processes or an extremely tall page. | Limit concurrency, reuse browser processes carefully, reduce dimensions, or split the page. |
| Works locally but fails in production | Adapter, binary, sandbox, memory, or execution-time differences. | Use a server adapter, install the browser in the deployment image, and verify provider limits. |
| Inconsistent pixels | Animations, ads, timestamps, or responsive layout changes. | Disable animations, set a fixed viewport/timezone, hide dynamic regions, and use masking. |
13. Performance, reliability, and cost
- Browser lifecycle: launching Chromium for every request is simple but slower. A controlled browser pool can reduce startup work.
- Concurrency: more pages increase throughput until CPU or memory becomes the bottleneck. Bound concurrent captures and queue excess work.
- Readiness: waiting for a precise selector is usually more predictable than waiting forever for network idle.
- Payload: WebP or JPEG can reduce transfer size; PNG preserves lossless detail.
- Caching: cache public, deterministic captures using all visual options in the cache key.
- Retries: retry transient navigation failures with a limit and backoff; do not retry invalid URLs or selector errors.
- Observability: record duration, status, target host, readiness policy, output bytes, and failure category without logging credentials.
- Cost: account for browser CPU, memory, bandwidth, storage, and provider execution time. Large full-page captures and high concurrency raise resource use.
14. Or skip the browser setup
ScreenshotNeo provides a website screenshot API with one GET request. See the API documentation for all options:
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)
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}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients 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 screenshots.
Sign up free for ScreenshotNeo.
15. FAQ
Can SvelteKit take screenshots in the browser?
A browser-only SvelteKit page cannot safely launch Playwright or hide its credentials. Put capture logic in a server route or call a screenshot service.
Should I use PNG, JPEG, or WebP?
Use PNG for lossless output, JPEG for photographic pages with a quality setting, and WebP when you want compact files supported by your consumers.
Why is network idle unreliable?
Analytics, polling, WebSockets, and advertisements can keep connections open. A page-specific readiness selector with a timeout is more deterministic.
Can a static SvelteKit site run this endpoint?
No. A static build has no server process to launch Chromium. Deploy a server adapter or call an external browser service.
How do I capture only a component?
Use page.locator('selector').screenshot() after waiting for that locator to become visible.


