DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to Take Full-Page Screenshots of Single-Page Applications (React, Vue, Next.js)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Each screenshot tool breaks at a different point:

  1. Screenshot immediately after load

    • ❌ Result: skeleton screen, no content
  2. Screenshot after a fixed delay (e.g., 2 seconds)

    • ❌ Result: some content loaded, some still loading, layout unstable
  3. Screenshot after "page load" event fires

    • ❌ Result: images still loading, lazy-loaded sections not visible
  4. Screenshot when network is idle

    • ⚠️ Works sometimes, but misses infinite scrollers and lazy loaders
  5. 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
Enter fullscreen mode Exit fullscreen mode

What this does:

  1. Navigate to the URL
  2. Wait for #content to appear (JavaScript has loaded, components have mounted)
  3. Scroll through the full page to trigger lazy loading
  4. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Better:

// ✅ Good: Wait for specific content
await page.waitForSelector('#content-loaded');
const screenshot = await page.screenshot();
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Fixed Delays (Brittle)

// ❌ Bad: What if the API is slow?
await new Promise(r => setTimeout(r, 2000));
const screenshot = await page.screenshot();
Enter fullscreen mode Exit fullscreen mode

Better:

// ✅ Good: Wait for network to idle
const response = await api.post('/screenshot', {
  waitForSelector: '#content',
  waitUntil: 'networkidle0'
});
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Screenshot Before Full-Page Scroll

// ❌ Bad: Lazy-loaded content below fold isn't loaded
const screenshot = await page.screenshot();
Enter fullscreen mode Exit fullscreen mode

Better:

// ✅ Good: Scroll to trigger lazy loading
const response = await api.post('/screenshot', {
  fullPage: true,
  fullPageScroll: true
});
Enter fullscreen mode Exit fullscreen mode

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: 500 handles most React state updates
  • delay: 2000 for 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.

Get started free →

Top comments (0)