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.
In this guide
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.
- The canonical link. The Almanac found a canonical missing from the raw HTML but present after rendering on only about 2% of pages. It says script-built canonicals add room for error, and that a static, server-side tag is generally the safer option. See canonical tags.
- Structured data. Only 2% of crawls had it added by JavaScript. The Almanac says that although Google can process script-injected markup, putting it in the initial HTML avoids potential delays, crawling issues and a poor user experience. See structured data basics.
- Title, meta description, robots rules and language links. The same logic applies. A page whose raw title is "Loading…" is telling every tool that does not run script that it has no name.
- Headings and the main text. The words you want found should be in the first response, not fetched later.
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.
Links must be links
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 markup | Flagged? |
|---|---|
<a href="/about/">About</a> | No |
<a href="javascript:void(0)" onclick="..."> | Yes |
<a onclick="..."> with no href | Yes |
<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
- View the source of your key pages and look for the title, description, canonical and main text.
- Run the comparison above on a home page, a product or service page and an article.
- Run Lighthouse and read the Links are crawlable and Links have descriptive text results.
- 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.
- 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
- A raw title of "Loading…" or a blank body.
- Tags added only by script, such as canonicals and robots rules.
- Navigation built with `span` or `button` instead of
a href. - `javascript:` links.
- One address for the whole app.
- A 200 on a missing page.
- Main text that loads only after a scroll or click.
- Blocking script or style folders in
robots.txt. A crawler that obeys the rule will not fetch them, so it cannot see the page as visitors do.
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
- SEO chapter, Web Almanac 2025: raw and rendered canonicals, raw and rendered word counts, structured data added by JavaScript, and invalid
headelements. - The Lighthouse 13.5 source for the crawlable anchors audit and its audit text, the Lighthouse documentation on link text and HTTP status codes, and Disable JavaScript in the Chrome DevTools documentation.
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