You open the page source, and it's nearly empty - just a <div id="root"></div> and a bunch of JavaScript bundles. The data you need is rendered client-side. BeautifulSoup parses the HTML you fetched, but that HTML has no content.

Welcome to scraping Single Page Applications. Here's how I approach them, starting with the least complex solution and escalating only when necessary.

Why SPAs Are Different

Traditional websites return complete HTML from the server. The data is in the page source - requests.get() + BeautifulSoup handles it.

SPAs (React, Vue, Angular) work differently:

  1. Server returns a shell HTML page with JavaScript bundles
  2. JavaScript executes in the browser
  3. JavaScript fetches data from API endpoints
  4. JavaScript renders the data into the DOM

If you fetch the page with an HTTP client, you get step 1 - the empty shell. The data arrives in step 3, and the content appears in step 4. Neither happens without a JavaScript engine.

But here's the key insight: the data still comes from an API. The JavaScript just fetches it and renders it. If you can find that API, you can skip the browser entirely.

Step 1: Find the Hidden API

Before reaching for a headless browser, spend 10 minutes in Chrome DevTools:

  1. Open the target page
  2. Open DevTools → Network tab
  3. Filter by Fetch/XHR
  4. Reload the page
  5. Look through the requests - the data is in one of them

Most SPAs fetch data from a REST or GraphQL endpoint. The response is usually JSON - clean, structured, and much easier to work with than scraping HTML.

# Instead of scraping the rendered page...
# Just call the API directly
import httpx

response = httpx.get(
    "https://spa-app.com/api/products",
    params={"page": 1, "limit": 50},
    headers={"Accept": "application/json"}
)
products = response.json()

This is faster, more reliable, and uses a fraction of the resources compared to browser automation.

Finding GraphQL Endpoints

Many modern SPAs use GraphQL. Look for requests to /graphql or /api/graphql in the Network tab. The request body contains the query:

import httpx

query = """
query GetProducts($page: Int!) {
    products(page: $page) {
        items {
            id
            name
            price
            imageUrl
        }
        totalPages
    }
}
"""

response = httpx.post(
    "https://spa-app.com/graphql",
    json={
        "query": query,
        "variables": {"page": 1}
    }
)
data = response.json()["data"]["products"]["items"]

Copy the exact query from the Network tab. The API expects the same format the frontend sends.

Authentication Tokens

SPAs often authenticate API calls with a JWT or session token. Check the request headers in DevTools:

# If the API requires auth
headers = {
    "Authorization": "Bearer eyJhbGciOiJIUzI1NiIs...",
    "X-CSRF-Token": "abc123",
}
response = httpx.get("https://spa-app.com/api/data", headers=headers)

Some tokens are generated during page load - you might need to fetch the page first, extract the token, then call the API. More on this below.

Step 2: Intercept the Data at the Network Level

Sometimes the API requires parameters that are hard to replicate - signed tokens, encrypted payloads, or dynamic request IDs generated by JavaScript. In these cases, let the browser do the work and intercept the API responses:

from playwright.sync_api import sync_playwright

products = []

def handle_response(response):
    if "/api/products" in response.url:
        data = response.json()
        products.extend(data["items"])

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()

    # Intercept API responses
    page.on("response", handle_response)

    page.goto("https://spa-app.com/products")
    page.wait_for_load_state("networkidle")

    # If there's pagination, click through it
    while True:
        next_btn = page.query_selector('button.next-page:not([disabled])')
        if not next_btn:
            break
        next_btn.click()
        page.wait_for_load_state("networkidle")

    browser.close()

print(f"Collected {len(products)} products")

This approach uses the browser to navigate and trigger API calls, but extracts the raw JSON instead of scraping rendered HTML. You get the best of both worlds - the browser handles authentication and JavaScript, while you get clean structured data.

Step 3: Request Interception and Modification

Playwright can intercept and modify requests before they're sent. This is useful for:

  • Adding custom headers
  • Blocking unnecessary resources (images, CSS, fonts) to speed things up
  • Modifying API parameters on the fly
from playwright.sync_api import sync_playwright

def route_handler(route):
    # Block images and fonts to speed up scraping
    if route.request.resource_type in ['image', 'font', 'stylesheet']:
        route.abort()
    else:
        route.continue_()

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()

    # Block unnecessary resources
    page.route("**/*", route_handler)

    page.goto("https://spa-app.com/products")
    # ... scrape data ...

Blocking images and fonts can make Playwright scraping 2-5x faster.

Step 4: Rendering JavaScript Server-Side

For pages where you just need the rendered HTML (no interaction required), you can use a lightweight approach - render the JavaScript once and parse the result:

from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://spa-app.com/products")
    page.wait_for_selector('.product-list')

    # Get the fully rendered HTML
    html = page.content()
    browser.close()

# Now parse with BeautifulSoup as if it were a static page
soup = BeautifulSoup(html, 'html.parser')
products = soup.find_all('div', class_='product-card')
for p in products:
    name = p.find('h2').text
    price = p.find('.price').text
    print(name, price)

This is the "render then parse" approach. The browser handles JavaScript execution, then you parse the result with familiar tools.

Step 5: Reverse Engineering the JavaScript

For high-volume scraping where browser automation is too slow, the nuclear option is reverse engineering the JavaScript itself - understanding how the SPA generates tokens, signs requests, or decrypts data.

Finding the Relevant Code

  1. Open DevTools → Sources tab
  2. Search for the API endpoint URL in the JavaScript files
  3. Set breakpoints on the function that makes the API call
  4. Step through to understand how parameters are constructed

Deobfuscation

Many SPAs minify and obfuscate their JavaScript. Tools that help:

  • Prettier/beautify - Reformats minified code into readable format
  • Source maps - Sometimes included in production (check for .map files)
  • Debugger breakpoints - Even in minified code, the debugger shows variable values at runtime
// Minified (unreadable)
const a=b(c.d,e.f);fetch("/api/data",{headers:{"X-Token":a}})

// After beautify + debugging
const token = generateToken(user.id, session.key);
fetch("/api/data", { headers: { "X-Token": token } })

Once you understand the token generation, you can replicate it in Python:

import hashlib
import time

def generate_token(user_id, session_key):
    timestamp = str(int(time.time()))
    payload = f"{user_id}:{session_key}:{timestamp}"
    return hashlib.sha256(payload.encode()).hexdigest()

token = generate_token("12345", "session_abc")
response = httpx.get(
    "https://spa-app.com/api/data",
    headers={"X-Token": token}
)

This is the most work but produces the fastest and most reliable scraper - no browser, no overhead, just HTTP requests with the correct parameters.

Decision Matrix

Scenario Approach Speed Complexity
API visible in Network tab Direct API call Fastest Low
API needs dynamic tokens Browser + response interception Medium Medium
Need rendered HTML only Browser render + BeautifulSoup Slow Low
Need to interact (click, scroll) Full browser automation Slowest Medium
High volume, complex auth Reverse engineer the JS Fastest High

Practical Tips

1. Check for Server-Side Rendering

Many "SPAs" actually use SSR (Next.js, Nuxt.js). The initial HTML might contain the data even though the page is a React/Vue app:

response = httpx.get("https://nextjs-app.com/products")
# Check if the data is already in the HTML
if "product-data" in response.text:
    # SSR page - parse normally
    soup = BeautifulSoup(response.text, 'html.parser')

2. Check for `__NEXT_DATA__` or `__NUXT__`

Next.js embeds page data in a script tag:

import json
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, 'html.parser')
next_data = soup.find('script', id='__NEXT_DATA__')
if next_data:
    data = json.loads(next_data.string)
    products = data['props']['pageProps']['products']

This is a goldmine - the raw data is right there in the page, no API call needed.

3. Watch for WebSocket Data

Some SPAs use WebSockets instead of REST APIs for real-time data. Check the WS tab in DevTools. Playwright can intercept WebSocket messages too:

def handle_ws(ws):
    ws.on("framereceived", lambda payload: print(f"Received: {payload}"))

page.on("websocket", handle_ws)

Key Takeaways

  • Always check the Network tab first - 80% of SPAs have exposed API endpoints
  • Direct API calls are 10-100x faster than browser automation
  • Network interception lets you use the browser for auth but extract clean JSON
  • Block images/fonts/CSS when using Playwright to speed up scraping
  • Next.js/Nuxt.js apps often have data embedded in __NEXT_DATA__ or __NUXT__
  • Reverse engineering JavaScript is the most work but produces the best scrapers
  • The goal is always to find the simplest approach that works - start with HTTP, escalate to browser only when necessary

The data is always there. The question is just how many layers you need to peel back to find it.


Always respect robots.txt and terms of service. Use these tools responsibly and with authorization.