How to Take Full-Page Screenshots of Single-Page Applications (React, Vue, Next.js)
You're building a screenshot tool for your React dashboard.
You take a screenshot of the page. The image shows... half the content. The bottom half is blank.
You take another screenshot. This time it shows more content, but images are still loading.
Third attempt. The layout shifts as lazy-loaded components mount. The screenshot is misaligned.
Fourth attempt... you give up and just use manual Playwright.
The Problem: SPAs Break Naive Screenshot Tools
Single-Page Applications (React, Vue, Next.js) render differently than traditional server-rendered HTML.
Traditional HTML:
Request → Server renders full HTML → Response → Browser displays everything
SPA:
Request → Server sends skeleton → Browser receives HTML → JavaScript loads →
Components mount → API calls fire → Data arrives → Components re-render →
Lazy-loaded sections appear → Images load → Layout stabilizes
Each screenshot tool breaks at a different point:
-
Screenshot immediately after load
- ❌ Result: skeleton screen, no content
-
Screenshot after a fixed delay (e.g., 2 seconds)
- ❌ Result: some content loaded, some still loading, layout unstable
-
Screenshot after "page load" event fires
- ❌ Result: images still loading, lazy-loaded sections not visible
-
Screenshot when network is idle
- ⚠️ Works sometimes, but misses infinite scrollers and lazy loaders
-
Full-page screenshot that scrolls and captures
- ❌ Result: components re-render as they come into view, screenshots are inconsistent
The real solution: Wait for the specific elements you care about, then capture the full page.
The Solution: Smart Wait Strategies
Instead of guessing when the page is "ready," tell the screenshot tool:
"Wait until this specific element appears, then capture the full page."
# Wait for #content to appear, then take a full-page screenshot
curl -X POST https://api.pagebolt.dev/v1/screenshot \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://myapp.com/dashboard",
"waitForSelector": "#content",
"fullPage": true,
"fullPageScroll": true
}' \
-o dashboard.png
What this does:
- Navigate to the URL
-
Wait for
#contentto appear (JavaScript has loaded, components have mounted) - Scroll through the full page to trigger lazy loading
- Capture everything as a single image
Result: Perfect screenshot of the fully-rendered SPA.
Real-World Examples
Example 1: React Dashboard
Your React dashboard loads like this:
export default function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData().then(data => {
setData(data);
setLoading(false);
});
}, []);
if (loading) return <Skeleton />;
return (
<div id="dashboard-content">
<Header />
<Sidebar />
<MainContent data={data} />
<Footer />
</div>
);
}
Smart screenshot strategy:
import axios from 'axios';
import fs from 'fs';
async function screenshotReactDashboard(userId) {
/**
* Screenshot a React dashboard.
* Wait for #dashboard-content to appear, then capture full page.
*/
const response = await axios.post(
'https://api.pagebolt.dev/v1/screenshot',
{
url: `https://myapp.com/dashboard/${userId}`,
waitForSelector: '#dashboard-content', // Wait for React to render
fullPage: true, // Capture entire page height
fullPageScroll: true, // Scroll to trigger lazy loading
format: 'png'
},
{
headers: { Authorization: `Bearer ${process.env.PAGEBOLT_API_KEY}` },
responseType: 'arraybuffer'
}
);
fs.writeFileSync(`dashboard-${userId}.png`, response.data);
return `dashboard-${userId}.png`;
}
// Usage
screenshotReactDashboard('user123');
Result: Waits for React state to settle, captures the fully-rendered dashboard.
Example 2: Vue.js Page with Lazy Images
Your Vue app uses lazy image loading:
<template>
<div id="product-page">
<h1>{{ product.title }}</h1>
<img v-lazy="product.image" />
<section v-for="section in sections">
<h2>{{ section.title }}</h2>
<img v-lazy="section.image" />
</section>
</div>
</template>
<script>
export default {
data() {
return {
product: null,
sections: []
}
},
mounted() {
this.loadProduct();
},
methods: {
loadProduct() {
fetch(`/api/product/${this.$route.params.id}`)
.then(r => r.json())
.then(data => {
this.product = data.product;
this.sections = data.sections;
});
}
}
}
</script>
Problem: Images are lazy-loaded. A screenshot taken immediately shows placeholder images.
Solution:
async function screenshotVuePage(productId) {
/**
* Screenshot a Vue page with lazy-loaded images.
* Wait for product data to load, scroll to trigger image loading.
*/
const response = await axios.post(
'https://api.pagebolt.dev/v1/screenshot',
{
url: `https://myapp.com/products/${productId}`,
waitForSelector: '#product-page', // Wait for Vue component to mount
waitUntil: 'networkidle2', // Wait for all images to load
fullPage: true,
fullPageScroll: true, // Scroll = load lazy images
delay: 1000 // Extra buffer for animation
},
{
headers: { Authorization: `Bearer ${process.env.PAGEBOLT_API_KEY}` },
responseType: 'arraybuffer'
}
);
return response.data;
}
Result: Waits for Vue to hydrate, scrolls to load all lazy images, captures perfect screenshot.
Example 3: Next.js Page with Hydration
Your Next.js app has complex hydration:
// pages/blog/[slug].jsx
export default function BlogPost({ post }) {
const [comments, setComments] = useState([]);
useEffect(() => {
// Hydration-only logic: load comments on client
fetch(`/api/comments/${post.id}`)
.then(r => r.json())
.then(data => setComments(data));
}, [post.id]);
return (
<article id="blog-post-content">
<h1>{post.title}</h1>
<p>{post.content}</p>
<CommentsSection comments={comments} />
</article>
);
}
Problem: Server renders the skeleton. Client hydrates and loads comments. Screenshot taken too early shows no comments.
Solution:
async function screenshotNextjsPage(slug) {
/**
* Screenshot a Next.js page with client-side hydration.
* Wait for hydration to complete and data to load.
*/
const response = await axios.post(
'https://api.pagebolt.dev/v1/screenshot',
{
url: `https://myapp.com/blog/${slug}`,
waitForSelector: '#blog-post-content', // Wait for SSR to complete
delay: 500, // Extra time for hydration
fullPage: true,
format: 'png'
},
{
headers: { Authorization: `Bearer ${process.env.PAGEBOLT_API_KEY}` },
responseType: 'arraybuffer'
}
);
return response.data;
}
Result: Waits for SSR + hydration + client-side data loading to complete.
Advanced: Multi-Selector Strategy
What if your page has multiple loading states?
async function screenshotComplexSPA(url) {
/**
* Screenshot a complex SPA with multiple loading states.
* Wait for main content, then specific sections.
*/
// Step 1: Wait for main app shell to render
const response = await axios.post(
'https://api.pagebolt.dev/v1/screenshot',
{
url: url,
waitForSelector: '#app-shell', // Main React app mounted
waitUntil: 'networkidle0', // All network requests done
fullPage: true,
fullPageScroll: true,
delay: 2000 // Extra time for animations
},
{
headers: { Authorization: `Bearer ${process.env.PAGEBOLT_API_KEY}` },
responseType: 'arraybuffer'
}
);
return response.data;
}
Key parameters:
| Parameter | Purpose | Example |
|---|---|---|
waitForSelector |
Wait for specific element to appear | #dashboard-content |
waitUntil |
Wait for network condition |
networkidle2, networkidle0, domcontentloaded
|
fullPage |
Capture entire page height (not just viewport) | true |
fullPageScroll |
Auto-scroll to trigger lazy loading | true |
delay |
Extra milliseconds after page is ready | 1000 |
Common Pitfalls
Pitfall 1: Using waitForNavigation (Too Short)
// ❌ Bad: Page might still be loading
await page.waitForNavigation({ waitUntil: 'load' });
const screenshot = await page.screenshot();
Better:
// ✅ Good: Wait for specific content
await page.waitForSelector('#content-loaded');
const screenshot = await page.screenshot();
Pitfall 2: Fixed Delays (Brittle)
// ❌ Bad: What if the API is slow?
await new Promise(r => setTimeout(r, 2000));
const screenshot = await page.screenshot();
Better:
// ✅ Good: Wait for network to idle
const response = await api.post('/screenshot', {
waitForSelector: '#content',
waitUntil: 'networkidle0'
});
Pitfall 3: Screenshot Before Full-Page Scroll
// ❌ Bad: Lazy-loaded content below fold isn't loaded
const screenshot = await page.screenshot();
Better:
// ✅ Good: Scroll to trigger lazy loading
const response = await api.post('/screenshot', {
fullPage: true,
fullPageScroll: true
});
Best Practices for SPA Screenshots
1. Know your app's loading sequence
- When does React mount?
- When do API calls complete?
- When do lazy images load?
- Use browser DevTools to trace this.
2. Find a stable selector
- Pick an element that appears once the app is ready
- Avoid elements that are present in skeletons
- Good:
#dashboard-content,[data-test-id="loaded"] - Bad:
.loading,.skeleton, generic divs
3. Always use fullPageScroll
- Triggers lazy loading as the page scrolls
- Ensures full-page capture matches rendered state
- Minimal performance cost
4. Use waitUntil: networkidle0 for data-heavy pages
- Waits for ALL network requests to complete
- Useful for pages with many API calls
- Can be slower but guarantees complete data
5. Test different wait times
- Some animations take longer than others
-
delay: 500handles most React state updates -
delay: 2000for complex state management
Cost & Scaling
Screenshots of SPAs count the same as regular screenshots:
| Plan | Requests/Month | Daily Screenshots | Use Case |
|---|---|---|---|
| Free | 100 | ~3 per day | Testing, demos |
| Starter | 5,000 | ~165 per day | Small app monitoring |
| Growth | 50,000 | ~1,650 per day | Production dashboards, bulk exports |
| Scale | Unlimited | Unlimited | Enterprise monitoring |
The Bottom Line
SPAs need smart screenshot strategies. Naive "wait 2 seconds" approaches fail because:
- React takes different times to render
- API calls are unpredictable
- Lazy loading depends on scroll position
- Animations add extra delay
The solution: Wait for the specific element you care about, tell the API to scroll and load everything, then capture.
PageBolt's waitForSelector + fullPageScroll + waitUntil parameters handle this. No Playwright overhead. One API call.
Ready to screenshot your SPA correctly?
Use waitForSelector to wait for your React/Vue/Next.js component to render. Free tier: 100 screenshots/month. Growth plan: 50,000/month.
Top comments (0)