ScreenshotNeo

BlogHow-to

Screenshot a Dynamically Rendered Next.js Page After Hydration

Wait for the page state you need—not just navigation—then capture it with Playwright. Includes runnable Node.js and Python examples, troubleshooting, and a one-call API option.

By the ScreenshotNeo team4 October 20268 min read

To screenshot a dynamically rendered Next.js page reliably, navigate with a browser, wait for a page-specific signal that proves the content you need is visible, and only then capture the page. In Playwright, that usually means waiting for a locator tied to the expected data—not merely waiting for load, a fixed delay, or network activity to stop.

Hydration and data readiness are different conditions. Hydration attaches React event handlers to server-rendered HTML so Client Components become interactive. A Client Component may still fetch data after hydration and update the page later. Your screenshot should wait for the state you intend to capture.

1. Understand what “after hydration” means

In the Next.js App Router, pages and layouts are Server Components by default. Client Components are used for browser APIs, effects, state, and event handlers. On an initial visit, Next.js can send prerendered HTML that appears before the client JavaScript has hydrated the interactive components. [Next.js: Server and Client Components]

Even after hydration, a component that fetches data in the browser may still show a loading state. For example, it can render a placeholder first and replace it after a client-side request completes. [Next.js: Client-side data fetching]

For that reason, define readiness in terms of what the screenshot must contain:

  • For a dashboard, wait for a data-backed value or table row.
  • For a loading transition, wait for the loading indicator to disappear and the result to appear.
  • For a user-specific view, set up the required authentication and state before navigation.
  • For a component screenshot, wait for that component’s content and capture the element itself.

2. Capture after a page-specific readiness check

Install Playwright and its Chromium browser in your project, then save this as screenshot.mjs. Replace the example URL and locator with a signal that proves the specific page data you need has loaded.

import { chromium } from 'playwright';

const browser = await chromium.launch();
try {
  const context = await browser.newContext({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1,
    locale: 'en-US',
    colorScheme: 'light',
  });
  const page = await context.newPage();

  await page.goto('http://localhost:3000/dashboard', {
    waitUntil: 'domcontentloaded',
    timeout: 30_000,
  });

  // Use a locator that appears only when the required client data is ready.
  await page.getByTestId('dashboard-total').waitFor({
    state: 'visible',
    timeout: 20_000,
  });

  await page.screenshot({
    path: 'dashboard.png',
    fullPage: true,
    animations: 'disabled',
  });
} finally {
  await browser.close();
}

Run it with node screenshot.mjs. Install the package and browser with npm install playwright and npx playwright install chromium. The locator is illustrative: use a stable test ID, accessible role and name, or another selector that corresponds to the actual state being captured.

Wait for a result, not just the page shell

A heading rendered by the server may be visible before the client request finishes. Prefer a locator whose presence or value depends on the result. If your application exposes a loading indicator, you can wait for it to disappear, but pair that with a check for the resulting content so a failed request does not look like success.

await page.getByTestId('loading-indicator').waitFor({ state: 'hidden' });
await page.getByRole('row', { name: /Acme Corporation/ }).waitFor({ state: 'visible' });

Playwright locators retry while waiting for their conditions, which makes them useful for asynchronous UI state. [Playwright: Locators]

Capture one element instead of the full page

If you only need a chart, card, or other component, wait for that element and call its screenshot method. This avoids capturing unrelated page content.

const chart = page.getByTestId('revenue-chart');
await chart.waitFor({ state: 'visible' });
await chart.screenshot({ path: 'revenue-chart.png' });

3. Set the browser context for repeatable captures

What appears on the page depends on the browser context as well as the URL. Set the variables that matter to your task:

Setting When to specify it
Viewport When layout, breakpoints, or visible content should match a particular screen size.
Device scale factor When output pixel density matters, including retina-style comparisons.
Locale and timezone When dates, numbers, currency, or localized content appear.
Color scheme When the page responds to light or dark mode preferences.
Authentication and storage state When the route requires a signed-in session or saved browser state.
Browser version When comparing captures across runs or machines.

Keep the viewport, browser, locale, device scale, and test data consistent when comparing images. Stabilize time-dependent content where practical. These controls improve the consistency of the conditions; they do not guarantee identical pixels if the page itself changes.

4. Choose navigation and waiting conditions carefully

Playwright navigation can wait for lifecycle events such as commit, domcontentloaded, load, or networkidle, and the Page API supports screenshots. [Playwright: Page API]

Condition What it tells you Does it prove your data is ready?
commit Navigation has committed and the response has begun. No.
domcontentloaded The document has been parsed and the event fired. No. Later client code or requests may still be running.
load The browser load event fired. No. It is not an application-specific data-ready signal.
networkidle No network connections for at least 500 ms. No. Quiet network activity does not prove the right UI is present.
Locator or assertion The selected UI condition is satisfied. Yes, if the condition represents the content you intend to capture.

Playwright explicitly discourages using networkidle as a general test-readiness shortcut and recommends using assertions tied to the page instead. Polling, analytics, streaming, or long-lived connections can prevent network quiet; conversely, a quiet network does not establish that the expected result rendered. [Playwright: Page API]

A fixed sleep has similar drawbacks: it may be too short on a slow run, waste time on a fast run, and cannot distinguish a successful render from a failed request. Use a timeout on a meaningful condition so the script fails with a useful signal when the page never becomes ready.

5. Python example with Playwright

Python can use the same browser workflow and readiness strategy. Install Playwright and Chromium, then save this as screenshot.py.

from pathlib import Path
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    try:
        context = browser.new_context(
            viewport={"width": 1440, "height": 900},
            device_scale_factor=1,
            locale="en-US",
            color_scheme="light",
        )
        page = context.new_page()
        page.goto(
            "http://localhost:3000/dashboard",
            wait_until="domcontentloaded",
            timeout=30_000,
        )

        # Replace this with a signal tied to the data you need.
        page.get_by_test_id("dashboard-total").wait_for(
            state="visible",
            timeout=20_000,
        )

        page.screenshot(path="dashboard.png", full_page=True)
    finally:
        browser.close()

Install with pip install playwright and playwright install chromium; run with python screenshot.py. The timeout values are examples. Choose limits that fit your application and execution environment.

6. Diagnose blank, stale, or loading-state screenshots

Symptom Likely cause What to change
Screenshot shows a loading placeholder The script waited for navigation or a server-rendered shell, not the client result. Wait for a locator that appears when the required data is rendered; optionally also check the loading indicator disappears.
Expected client data never appears The application request failed, authentication is missing, or the app is stuck. Inspect console errors and failed requests, verify session state and API responses, and fail the capture if the readiness locator times out.
Capture is blank or shows an error page The URL is unreachable, the route returned an error, or the app failed before rendering. Check the final URL and response, verify the app is running in the capture environment, and inspect browser console output.
Capture changes between runs Viewport, browser, locale, device scale, data, animation, or time-dependent content differs. Set context values explicitly and use stable test data; disable animations for captures where appropriate.
Hydration warning or mismatch appears The first client render differs from the server-rendered HTML. Compare server output with the initial client render and fix the source of the mismatch. A screenshot call cannot repair an application rendering issue.
Wait for network idle hangs Ongoing requests such as polling or long-lived connections keep the network active. Wait for the expected UI state instead of requiring all network activity to stop.
Locator times out although content is visible The locator does not match the rendered element, or visibility is not the right state for the task. Inspect the DOM, refine the role/name or test ID, and choose the appropriate visible, attached, or hidden condition.

For a hydration mismatch, Next.js recommends ensuring the initial server and client output match. Its guidance also describes intentional client-only changes using an effect, disabling prerendering for selected components, and using suppressHydrationWarning as a narrow escape hatch. That prop works only one level deep and does not cause React to patch mismatched text, so it is not a general screenshot fix. [Next.js: Hydration error guidance]

7. Performance, reliability, and cost

A browser capture includes starting or reusing a browser, navigating, waiting for the application-specific condition, and writing the image. The readiness condition usually determines whether the capture is correct; making it shorter is useful only if it still proves the needed state.

  • Performance: Reuse a browser process for multiple captures when appropriate, keep readiness checks specific, and avoid arbitrary long sleeps. Full-page screenshots may take longer and create larger files than element captures.
  • Reliability: Use bounded timeouts, detect failed navigation or missing content, and preserve the same browser context and test data for repeatable jobs. Treat a timeout as a failed capture rather than saving a misleading image.
  • Cost: A self-hosted Playwright workflow uses your own execution environment. Account for the compute and maintenance needed to install browsers and run jobs. If you need a managed screenshot request instead, ScreenshotNeo charges by plan and says only clean shots are billed; bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing.

8. Or skip the browser setup

If you need a screenshot without installing and operating a browser, ScreenshotNeo is a website screenshot API and MCP server. Its one-call API can capture a URL as PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for request options.

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

Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={
        "access_key": "YOUR_API_KEY",
        "url": "https://your-nextjs-site.example/dashboard",
    },
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://your-nextjs-site.example/dashboard',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) =>
  writeFile('shot.webp', Buffer.from(await res.arrayBuffer()))
);

For a private page, configure the appropriate authentication or headers using the supported API options. A screenshot API still needs a readiness condition suited to the page; consult the docs for available wait settings and selectors. ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot; bot checks, blank pages, and failed loads are never billed; its MCP server lets AI agents take screenshots; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Sign up for ScreenshotNeo’s free plan.

9. Frequently asked questions

Can I detect React hydration directly?

For screenshot correctness, prefer a visible application condition that proves the content is ready. A generic hydration signal may only show that event handlers were attached; client-fetched content can still be pending.

Should I wait for networkidle?

Usually not as the only readiness check. It measures a period of network quiet, not whether the page reached the specific state you want.

Can I capture a page that requires sign-in?

Yes. Create a browser context with the required authenticated storage state or perform the sign-in flow before navigating to the target. Keep credentials and session material out of source control.

Does a hydration mismatch mean the screenshot tool is broken?

No. A mismatch is an application rendering issue. Diagnose why the server output and initial client render differ before relying on the captured page.