ScreenshotNeo

BlogHow-to

How to Take a Screenshot of a Website in Remix

Use Playwright with your Remix app to capture viewport, full-page, or component screenshots, with runnable TypeScript examples and troubleshooting.

By the ScreenshotNeo team1 October 20267 min read

Direct answer: Remix serves the application and its routes; a browser automation tool such as Playwright captures the rendered page. Start your Remix app, open its URL with Playwright, and call page.screenshot().

import { chromium } from "playwright";

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("http://localhost:3000");
await page.screenshot({ path: "screenshot.png" });
await browser.close();

Use fullPage: true for the entire scrollable document, or capture one component through a locator. Remix does not capture pixels by itself: its route layer returns Web Response objects, while Playwright controls a real browser.

1. Choose the capture scope

Goal Playwright call Result
Visible viewport page.screenshot({ path: "page.png" }) Only the currently visible area
Entire page page.screenshot({ path: "full-page.png", fullPage: true }) The complete scrollable document
One component page.locator(".target").screenshot({ path: "component.png" }) The selected element and its bounds
Further processing const bytes = await page.screenshot() Image bytes in memory instead of a file

Playwright supports PNG, JPEG, and WebP output. A CSS-pixel scale keeps output smaller on high-density screens; a device-pixel scale preserves more physical pixels. Set the viewport and device scale factor when reproducible dimensions matter.

2. Install Playwright and run a capture script

Install Playwright in the project that will perform the capture, then install a browser runtime:

npm install playwright
npx playwright install chromium

Start the Remix development server separately (for example, on port 3000), then create scripts/capture.ts:

import { chromium } from "playwright";

const url = process.argv[2] ?? "http://localhost:3000";
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
  viewport: { width: 1440, height: 900 },
  deviceScaleFactor: 1,
});

await page.goto(url, { waitUntil: "networkidle" });
await page.screenshot({ path: "screenshot.png", type: "png" });
await browser.close();

Run it with your Remix URL:

npx tsx scripts/capture.ts http://localhost:3000

For a production build, point the script at the deployed HTTPS URL. The URL must be reachable from the machine running Chromium.

3. Wait for Remix content before capturing

Remix can render an initial document and then load route data or browser-only components. Capture after a condition that represents readiness instead of relying only on a fixed delay.

await page.goto("http://localhost:3000/dashboard", {
  waitUntil: "domcontentloaded",
});
await page.locator("[data-capture-ready]").waitFor({ state: "visible" });
await page.screenshot({ path: "dashboard.png", fullPage: true });

Useful readiness conditions include a stable selector, a visible heading, or a known application state. Use waitUntil: "networkidle" when the page has finite network activity; pages with analytics, polling, or streaming requests may never become idle, so a selector is safer.

4. Capture one Remix component

Add a stable attribute to the component you want to export:

export default function PricingRoute() {
  return (
    <main>
      <section data-capture-target className="pricing-card">
        {/* pricing content */}
      </section>
    </main>
  );
}

Capture that element from Playwright:

await page.goto("http://localhost:3000/pricing");
const card = page.locator("[data-capture-target]");
await card.waitFor({ state: "visible" });
await card.screenshot({ path: "pricing-card.webp", type: "webp" });

A locator screenshot is useful for cards, charts, invoices, and previews. It avoids stitching the rest of the document into the output.

5. Return a screenshot from a Remix route

If your application needs an endpoint that returns an image, keep the capture work on server-side infrastructure that can run a browser. The Remix route is responsible for the HTTP response; Playwright still performs the capture.

// app/routes/screenshot.ts
import { chromium } from "playwright";
import type { LoaderFunctionArgs } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url).searchParams.get("url");
  if (!url) {
    return new Response("Missing url", { status: 400 });
  }

  const browser = await chromium.launch({ headless: true });
  try {
    const page = await browser.newPage({
      viewport: { width: 1440, height: 900 },
    });
    await page.goto(url, { waitUntil: "networkidle" });
    const bytes = await page.screenshot({ type: "png", fullPage: true });
    return new Response(bytes, {
      headers: {
        "Content-Type": "image/png",
        "Cache-Control": "public, max-age=300",
      },
    });
  } finally {
    await browser.close();
  }
}

For a public endpoint, validate allowed hosts and protect it from SSRF before accepting arbitrary URLs. Limit navigation time, page size, and concurrency so one request cannot exhaust the server.

6. Configure output, viewport, and resolution

await page.setViewportSize({ width: 1280, height: 800 });
await page.screenshot({
  path: "hero.jpg",
  type: "jpeg",
  quality: 85,
  fullPage: false,
  scale: "css",
});

Use PNG for lossless UI and text, JPEG for photographic pages, and WebP when your downstream pipeline accepts it. Choose scale: "css" for predictable CSS-pixel dimensions; use device pixels when a high-resolution asset is required. A full-page image can become very tall, so set a practical viewport and consider element capture for individual sections.

7. Handle authentication, headers, and cookies

Create a browser context with the state required by the route. Keep credentials out of source control and avoid writing authenticated storage state to shared locations.

const context = await browser.newContext({
  extraHTTPHeaders: {
    Authorization: `Bearer ${process.env.PREVIEW_TOKEN}`,
  },
  locale: "en-US",
  timezoneId: "UTC",
});
const page = await context.newPage();
await context.addCookies([
  {
    name: "preview",
    value: "1",
    domain: "localhost",
    path: "/",
  },
]);
await page.goto("http://localhost:3000/preview");

8. Make captures deterministic

  • Set a fixed viewport, locale, timezone, and color scheme.
  • Wait for fonts, images, and a page-specific ready selector.
  • Disable animations in a capture-only stylesheet.
  • Use stable test data and deterministic timestamps.
  • Close the browser in a finally block.
await page.addStyleTag({
  content: `*, *::before, *::after {
    animation: none !important;
    transition: none !important;
    caret-color: transparent !important;
  }`,
});

9. Common errors and fixes

Error Cause Fix
Executable doesn't exist The browser binary is not installed. Run npx playwright install chromium in the execution environment.
Screenshot is blank The route failed, redirected, or was captured before rendering. Check page.url(), response status, console errors, and wait for a visible readiness selector.
Only the top of the page appears The default capture is viewport-only. Pass fullPage: true, or capture a specific locator.
Images or fonts are missing Assets are still loading or inaccessible to the browser. Wait for the relevant elements, verify asset URLs, and inspect failed network requests.
networkidle never resolves Analytics, polling, or streaming keeps requests open. Use domcontentloaded plus a page-specific selector or bounded timeout.
Works locally but fails in deployment The host lacks Chromium dependencies, has a read-only filesystem, or blocks outbound requests. Use a browser-capable runtime, install dependencies during build, and verify network access from that host.
Different pixels on each run Fonts, animations, time, ads, or responsive layout vary. Fix viewport and locale, wait for fonts, disable motion, and use stable content.

10. Performance, reliability, and cost

Launching a browser for every request is slower and more expensive than reusing a browser process. For batches, keep one browser open and create a fresh context per job. Bound navigation and capture timeouts, cap concurrent pages, and always close contexts. Full-page screenshots use more memory than viewport or element captures; process bytes promptly instead of retaining many large buffers.

For visual regression, keep the browser version and fonts fixed. Store the capture metadata (URL, viewport, commit, and timestamp) next to the image so a changed screenshot can be explained. A screenshot is a rendered artifact, not automatically a test assertion; compare it only when your workflow defines acceptable differences.

11. Or skip the browser setup

ScreenshotNeo provides a hosted screenshot API for Remix pages. It accepts a URL and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers.

See the ScreenshotNeo API documentation for the complete options. This one-call example captures a Remix deployment:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://your-remix-site.example.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://your-remix-site.example.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://your-remix-site.example.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());
require('fs').writeFileSync('shot.webp', bytes);

ScreenshotNeo also supports full-page and element capture, dark mode, device presets, retina scale, custom CSS and JavaScript, clicks, waits, blocked resources, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, selectable caching TTLs, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an MCP server with take_screenshot, get_page_info, and capture_pdf for AI clients such as Claude and Cursor. There are 1,000 free screenshots each month with no card; paid plans start at $5 for 3,000 screenshots. Create a free ScreenshotNeo account.

12. FAQ

Does Remix have a built-in screenshot API?

No. Remix handles routes and responses. Use Playwright or a hosted capture service to drive a browser and produce the image.

How do I capture a page below the fold?

Pass fullPage: true to page.screenshot().

Can I capture only a React component?

Yes. Give it a stable selector and call page.locator(selector).screenshot().

Should capture code run in the browser?

Keep Playwright on trusted server-side infrastructure. Sending browser automation to every end user exposes credentials and increases resource usage.

Which Remix version should I follow?

Check whether the project uses a legacy Remix release or the current React Router framework mode before applying framework-specific deployment guidance.