JavaScript SEO: how to check the server HTML matches what renders
Every page has two versions: the server HTML that arrives first, and the rendered page after JavaScript has run. Google reads the server HTML first and the rendered page later, once it has had time to render it. Many other crawlers, including the major AI crawlers when last studied, only read the first. If your title, description, canonical or robots tag only appears after JavaScript runs, those crawlers see something else. To check, compare the page source with the rendered page, or run the snippet below.
Every page has two versions
A site built with a JavaScript framework often sends a near-empty shell, then fills in the page in the browser. It looks perfect on screen. Here's what a crawler that doesn't run JavaScript gets:
<head> <title>Example Store</title> <link rel="canonical" href="https://example.com/"> <script src="/app.js"></script> </head> <body> <div id="root"></div> ← empty </body>
<head> <title>Blue Widgets | Example Store</title> <link rel="canonical" href="https://example.com/widgets/blue"> <meta name="description" …> </head> <body> <h1>Blue Widgets</h1> … </body>
To a crawler that skips JavaScript, every product page on this site has the same title and a canonical pointing at the homepage. It looks like thousands of copies of one page.
Who sees which version
Google handles JavaScript pages in three phases: it crawls the server HTML, queues the page for rendering, then indexes what rendered. Google says pages may sit in that queue for a few seconds, or longer. Many other crawlers don't render at all:
| Who | Reads | Notes |
|---|---|---|
| Googlebot | Server HTML, then rendered | Rendering can be delayed. A noindex in the server HTML can stop it rendering at all. |
| Google's Gemini, Applebot | Rendered | Gemini relies on Googlebot's crawling and rendering. Applebot renders with a browser-based crawler. |
| GPTBot, ClaudeBot, PerplexityBot | Server HTML only (Dec 2024) | A Vercel study of AI crawlers (December 2024) found none of the major ones ran JavaScript. |
| Link previews | Server HTML, usually | Slack, LinkedIn and Facebook previews generally read the tags the server sends. |
So "Google renders JavaScript" is true, but it's no longer the whole question. If you want a page described properly in AI answers and link previews, its key tags have to be in the server HTML.
What's safe to set with JavaScript
<a> elements with an href. A button or onclick that changes the page isn't followed.How to check by hand
1Compare both versions with a snippet 1 minute
Open DevTools (F12), go to the Console and paste:
// Compares the server HTML with the rendered page for the tags crawlers care about
const res = await fetch(location.href, { cache: 'no-store', headers: { Accept: 'text/html' } });
const server = new DOMParser().parseFromString(await res.text(), 'text/html');
// Only noindex and nofollow matter for robots; dropping a harmless "index, follow" changes nothing.
const restrictive = doc => [...doc.querySelectorAll('meta[name="robots" i], meta[name="googlebot" i]')]
.flatMap(m => (m.getAttribute('content') || '').toLowerCase().split(/[\s,]+/))
.flatMap(t => t === 'none' ? ['noindex', 'nofollow'] : [t])
.filter((t, i, all) => (t === 'noindex' || t === 'nofollow') && all.indexOf(t) === i).sort().join(', ');
const read = doc => ({
title: doc.title.trim(),
description: doc.querySelector('meta[name="description" i]')?.getAttribute('content')?.trim() || '',
canonical: doc.querySelector('link[rel="canonical" i]')?.href || '',
robots: restrictive(doc),
h1: doc.querySelector('h1')?.textContent.replace(/\s+/g, ' ').trim() || '',
});
const a = read(server), b = read(document);
console.table(Object.keys(a).map(field => ({
field,
'server HTML': a[field] || '(none)',
'rendered page': b[field] || '(none)',
match: a[field].toLowerCase() === b[field].toLowerCase() ? 'same' : 'DIFFERENT',
})));
It fetches the page's HTML again, reads it without running any scripts, and lines it up against the page in front of you: title, description, canonical, robots and H1, each marked same or DIFFERENT.
It compares the first title, description, canonical and H1, and the noindex and nofollow rules from every robots tag. It sees what your browser is sent: a site that serves Googlebot different HTML won't show that here.
2Turn JavaScript off and look 2 minutes
- Open DevTools and press
Ctrl+Shift+P(⌘+Shift+Pon a Mac). - Type Disable JavaScript, press Enter, then reload the page.
- What you see now is roughly what a crawler that skips JavaScript gets. Check the tab title, the heading and whether the content and links are there.
- Run Enable JavaScript the same way when you're done. It only lasts while DevTools is open.
3See what Googlebot rendered 3 minutes
In Google Search Console, paste the URL into URL Inspection, choose Test live URL, then View tested page. The HTML tab shows the page as Googlebot rendered it, and the screenshot shows whether the content made it in. Search inside the HTML for your title and canonical.
How to fix it
- Put each page's own tags in the server HTML. Server-side rendering or static generation does this. Most frameworks set the title, description and canonical per page during the server render. Dynamic rendering (a separate version for bots) is a workaround Google doesn't recommend long term.
- Fix the shell's defaults if you can't render on the server yet. A missing canonical is better than one that points every page at the homepage.
- Never ship noindex expecting JavaScript to remove it. Decide indexing in the server HTML.
- Use real links for navigation and pagination:
<a href="/page-2">, with JavaScript on top if you like. - Handle "not found" properly in single-page apps. Google suggests either sending the visitor to a URL that returns a real 404, or adding a noindex robots tag to the error view.
How crawlers report this
If a crawl sent you here, these are the labels to look for. Our guide to Screaming Frog and Sitebulb issues covers other common ones.
| Screaming Frog | Sitebulb | What it means |
|---|---|---|
| JavaScript: Noindex Only in Original HTML | Noindex only in the HTTP response HTML | The server sends noindex and JavaScript removes it. Google may skip rendering, so the noindex can stand. |
| JavaScript: Page Title Only in Rendered HTML | Page Title only in the rendered HTML | The server HTML has no title, so it depends on rendering. |
| JavaScript: Page Title Updated by JavaScript | Page Title modified by JavaScript | The title changes after load. |
| JavaScript: H1 Only in Rendered HTML | <h1> only in the rendered HTML | The main heading appears only after JavaScript runs. |
| JavaScript: Canonical Mismatch | Canonical mismatch between rendered and response HTML | The canonical changes after load, which sends mixed signals. |
| JavaScript: Canonical Only in Rendered HTML | Canonical only in the rendered HTML | The canonical is added by JavaScript, not sent by the server. |
Doing this on every page?
SEOSnip compares both versions for you
Open the extension on any page and it reads the server HTML alongside the rendered page, comparing the title, description, canonical, H1 and robots directives. You get:
- Each field that JavaScript adds, removes or changes, side by side
- Marked as an error when the title, canonical or robots rules change, and a warning when only the description or H1 differs
- A copyable snippet of what the server HTML should contain
- A ticket with that evidence, and a "Check if fixed" button that re-runs the comparison
Server HTML title and canonical URL differ from the rendered page
| Element | Server HTML | Rendered page |
|---|---|---|
<title> | Example Store | Blue Widgets | Example Store |
canonical | https://example.com/ | https://example.com/widgets/blue |