JavaScript and SEO: what crawlers can see

How pages built with JavaScript can hide tags, text and links from search engines, how to compare raw and rendered HTML, and how to fix the common gaps.

IntermediateExplainer6 min readUpdated October 30, 2026

In this guide
  1. Raw HTML and rendered page
  2. What belongs in the raw HTML
  3. Links must be links
  4. One address per view
  5. Content behind clicks and scrolls
  6. The speed side
  7. Compare raw and rendered
  8. How to check
  9. Mistakes to avoid
  10. Check your own page
  11. Sources

Raw HTML and rendered page

When you open a page, the browser downloads HTML and then runs the scripts. A script can change the page: set the title, add text, build a menu, fetch products. The page you see is the rendered page. The HTML before any of that is the raw page. "View source" shows the raw page. The Elements panel in DevTools shows the rendered one.

For many sites the two are almost the same. The 2025 Web Almanac measured both. On home pages, the median difference between the words in the raw HTML and the words visible after rendering was 18% on desktop and 16% on mobile. The Almanac puts part of that down to layouts such as accordions, where text is hidden on mobile even though it is in the page, and it notes that the more words a page has, the less it tends to rely on client-side rendering.

Where the two forms differ a lot, the page depends on script to say what it is.

What belongs in the raw HTML

The Almanac's advice, repeated across its SEO chapter, is that the most reliable approach is to put tags in the raw HTML, so that they are the same after rendering.

One related trap is not about script at all. The Almanac notes that an invalid element in the head, such as an img or div, can make the browser end the head early, and then tags below it, including canonicals and robots rules, stop working. See robots.txt and noindex.

Search engines follow links. Lighthouse's Links are crawlable audit says search engines may use href attributes to crawl a site, and that the href should point to an appropriate destination so more pages can be discovered. I ran Lighthouse 13.5 on a test page with several kinds of link:

The markupFlagged?
<a href="/about/">About</a>No
<a href="javascript:void(0)" onclick="...">Yes
<a onclick="..."> with no hrefYes
<a href="/services/" onclick="...">No
<span onclick="...">Not checked, because it is not a link at all

A span or a button that moves the visitor to a new page by script has no address a crawler can follow. Use <a href> for anything that goes to another page, and let script add behavior on top. Also use plain words for the link text, since Lighthouse flags generic text such as "click here" and "learn more".

One address per view

A single-page application can show many "pages" on one address. Give each view its own URL, using the History API so the address bar changes, and make sure the URL works when typed in. Each view also needs its own title and description, which script must update when the view changes.

Status codes need care too. Lighthouse's documentation warns about "sneaky 404s" in single-page apps: a page that does not exist can still be sent with a 200 and then shown as an error by script. A search engine that meets a 4xx or 5xx may not index a page properly, and a missing page should return a real 404. See redirects and status codes.

Content behind clicks and scrolls

Text that appears only after a click, a tap or a scroll depends on the reader doing the same. A crawler may not. If the text should be found, put it in the HTML, and use CSS or a small script to collapse it. That is what accordions that hold their content in the page do.

The speed side

A page that builds everything in the browser makes the visitor wait for the script before seeing anything. That is why server rendering and static output tend to help both SEO and speed: the first response already contains the page, and the script adds interactivity afterward. See hydration in React and Next.js for how the main frameworks do this, and bundle size and code splitting for shrinking what remains.

Compare raw and rendered

This script fetches a page's raw HTML, loads it in a real browser, and prints the main tags from both. It needs Node and Playwright, and its patterns are crude, so treat it as a quick check rather than a parser:

// compare.mjs: node compare.mjs https://example.com/page/
import { chromium } from 'playwright';

const url = process.argv[2];
const raw = await (await fetch(url)).text();

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
const rendered = await page.content();
await browser.close();

const pick = (html) => {
  const body = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ');
  return {
    title: (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1]?.trim() || null,
    description: (body.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)/i) || [])[1] || null,
    canonical: (body.match(/<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']*)/i) || [])[1] || null,
    h1: (body.match(/<h1[\s>]/gi) || []).length,
    links: (body.match(/<a\s[^>]*href=/gi) || []).length,
    words: body.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length,
  };
};
console.table({ raw: pick(raw), rendered: pick(rendered) });

I ran it on two small test pages. For the page built on the server, the raw and rendered rows were identical. For the page built in the browser, the raw row had the title "Loading…", no description, no canonical, no heading, no links and 1 word, while the rendered row had all of them and 18 words. A gap like that is the thing to fix.

How to check

  1. View the source of your key pages and look for the title, description, canonical and main text.
  2. Run the comparison above on a home page, a product or service page and an article.
  3. Run Lighthouse and read the Links are crawlable and Links have descriptive text results.
  4. Turn JavaScript off. Open the Command Menu in DevTools, run Disable JavaScript and reload. What you see is close to the raw page, and shows how much the page depends on script.
  5. Test real addresses. Type a deep address into the browser and check that it loads, and that a made-up one returns a 404.

Mistakes to avoid

Check your own page

Run the free audit and read the SEO findings. Then view the source of your home page and check that its title, main heading and navigation links are all there before any script runs.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Technical SEO · Book a call

Related guides

All guides →