ScreenshotNeo

BlogHow-to

How to Load HTML Generated by Solid.js in Pyppeteer

Render Solid.js HTML on the server, load it with Pyppeteer, and test SSR, streaming, hydration, timing, and screenshots reliably.

By the ScreenshotNeo team30 September 20265 min read

How to Load HTML Generated by Solid.js in Pyppeteer

Direct answer: render your Solid.js app on the server, then pass the resulting HTML string to Pyppeteer with await page.setContent(html). Use Solid’s renderToStringAsync when server suspense work must finish first. If the app is already running at a URL and you need its real browser requests, use await page.goto(url) instead. Loading an HTML string does not hydrate Solid or make event handlers run.

Choose the loading path

Goal Solid output Pyppeteer action What it tests
Static SSR snapshot renderToString(() => <App />) page.setContent(html) Synchronous server markup
SSR with suspense await renderToStringAsync(() => <App />) page.setContent(html) Markup after async boundaries settle
Hosted application Server renders the app at an HTTP endpoint page.goto(url, options) Navigation, scripts, styles, and requests
Streamed SSR renderToStream page.goto, then wait for an app condition Initial shell and later fragments
Interactive hydration Matching server and client trees plus bootstrap Load the complete document and wait for hydration Client events and reactive updates

Solid documents renderToString as synchronous server rendering and renderToStringAsync as rendering that waits for async suspense boundaries. Pyppeteer’s setContent assigns supplied markup; goto navigates to a URL.

The server-rendering and browser-loading boundary: generate HTML first, then choose setContent or goto.
The server-rendering and browser-loading boundary: generate HTML first, then choose setContent or goto.

1. Generate Solid HTML on the server

Keep server rendering in a server build. These APIs are not browser-bundle APIs.

import { renderToStringAsync } from 'solid-js/web';
import App from './App';

export async function renderApp() {
  return await renderToStringAsync(() => <App />);
}

// For a synchronous tree:
// import { renderToString } from 'solid-js/web';
// const html = renderToString(() => <App />);

Use renderToString only when unresolved suspense is acceptable. Use renderToStringAsync when data-dependent content must be present before the browser receives the string. Set an appropriate timeout and handle timeout failures explicitly.

2. Load the HTML string with Pyppeteer

This example assumes your server renderer is exposed at an endpoint returning the HTML string.

import asyncio
import urllib.request
import pyppeteer

async def main():
    html = urllib.request.urlopen('http://127.0.0.1:3000/ssr', timeout=30).read().decode('utf-8')
    browser = await pyppeteer.launch({'headless': True})
    try:
        page = await browser.newPage()
        await page.setContent(html)
        await page.waitForSelector('#app .expected-result')
        text = await page.Jeval('#app .expected-result', 'el => el.textContent')
        print(text)
        await page.screenshot({'path': 'solid-ssr.png', 'fullPage': True})
    finally:
        await browser.close()

asyncio.run(main())

setContent parses supplied markup. If the string is a fragment, use a complete document when relative URLs, styles, or browser behavior matter.

3. Navigate to an already-served Solid app

import asyncio
import pyppeteer

async def main():
    browser = await pyppeteer.launch({'headless': True})
    try:
        page = await browser.newPage()
        await page.goto('http://127.0.0.1:3000', {'waitUntil': 'domcontentloaded', 'timeout': 30000})
        await page.waitForSelector('#app .expected-result', {'timeout': 30000})
        await page.screenshot({'path': 'solid-app.png', 'fullPage': True})
    finally:
        await browser.close()

asyncio.run(main())

Pyppeteer exposes load, domcontentloaded, and networkidle0. The latter means zero active network connections for at least 500 ms; it is not proof that application state is ready. Prefer a selector or explicit ready signal.

4. Test hydration and interactivity

Static HTML contains no client event handlers. Solid’s hydrate attaches behavior to server DOM, and server markup must match the JSX returned by the client hydration function. Include hydrationScript once when your setup requires its bootstrap and delegated event replay.

import { hydrate } from 'solid-js/web';
import App from './App';

hydrate(() => <App />, document.getElementById('app'));
await page.waitForSelector('#app[data-hydrated]')
await page.click('#app button.increment')
value = await page.Jeval('#app .count', 'el => el.textContent')
assert value == '1'

Do not treat a setContent snapshot as a hydration test unless it includes the client bundle, data, and bootstrap. For a browser application test, navigate to the hosted page or make every referenced asset reachable.

5. Handle streamed SSR

renderToStream can flush a shell and write later async fragments. Navigate to the endpoint, then wait for the selector or ready signal that marks the content under test.

A clean capture removes common overlays before the screenshot is billed.
A clean capture removes common overlays before the screenshot is billed.
await page.goto('http://127.0.0.1:3000/stream', {'waitUntil': 'domcontentloaded'})
await page.waitForSelector("#report[data-ready='true']", {'timeout': 60000})

6. Make resources and timing deterministic

  • Use a stable local server URL and fixed test data.
  • Wait for a meaningful selector instead of an arbitrary sleep.
  • If fonts, images, or API calls affect the assertion, wait for those resources or expose an app-ready marker.
  • With setContent, provide a valid <base href='...'> or absolute asset URLs when relative URLs need an origin.
  • Close the browser in finally so failed assertions do not leak processes.

Common errors and fixes

Symptom Cause Fix
Suspense fallback appears renderToString returned before async work settled Await renderToStringAsync and set a timeout.
Button clicks do nothing Only static HTML was loaded Load the client bundle and hydrate, then wait for hydration.
Hydration warnings Server DOM and client JSX differ Use matching inputs, ordering, conditionals, and locale.
Missing styles or images Relative URLs lack a base or origin Use a complete document, valid base, absolute URLs, or goto.
goto times out Wrong URL, stopped server, or hanging request Verify the endpoint and inspect failed requests before increasing timeout.
networkidle0 never arrives Polling, analytics, websockets, or long requests Use domcontentloaded plus a page-specific ready condition.
Streamed content is absent Assertion ran after the shell but before fragments Wait for the fragment selector or ready state.
Browser remains running No cleanup path Close the browser in finally.

Performance, reliability, and cost notes

  • Performance: Reuse one browser process for a suite and create a page per case. Avoid global network-idle waits when a local selector is sufficient.
  • Reliability: Assert user-visible state, keep data deterministic, bound every wait, and capture console or request failures when diagnosing flakes.
  • Cost: Self-hosted Pyppeteer uses your runner’s compute and browser downloads. A screenshot API removes browser maintenance, so compare its billing and controls with your workload.

Or skip the browser setup

ScreenshotNeo returns a screenshot or PDF from one GET request. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the verdict and billing in X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API docs for 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}`);

1,000 screenshots per month are free with no card. Paid plans start at $5 for 3,000 shots; every feature is on every plan. Create a free ScreenshotNeo account.

FAQ

Can I pass a Solid component directly to Pyppeteer?

No. Render it to HTML in a server build first, then use setContent, or serve the app and use goto.

Does setContent execute Solid?

It parses the document. Interactivity requires the matching browser bundle and hydration.

Should I always use renderToStringAsync?

Use it when suspense or resource work must settle. A synchronous data-free tree can use renderToString.

Is networkidle0 enough?

No. Assert the selector or ready signal representing the state your test needs.