SEO & Digital Marketing Module 2 – Technical SEO: crawling and indexing
JavaScript SEO: rendering and its traps
How Googlebot crawls, renders and indexes JavaScript pages, how to compare the server HTML with the rendered page, and when to render on the server.
What you will learn
- Explain how Googlebot crawls, renders and indexes a JavaScript page, and what changes for answers other than 200
- Compare a page's server HTML with its rendered HTML to find the content, links and tags that only scripts add
- Fix the common traps, from links without href to a noindex or canonical that a script changes
- Answer with a real 404 from a single-page app for an address that has no content
- Choose between server-side rendering, static generation and client-side rendering for pages that must be found
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how Googlebot crawls, renders and indexes a JavaScript page, and what changes for answers other than 200
- Compare a page's server HTML with its rendered HTML to find the content, links and tags that only scripts add
- Fix the common traps, from links without href to a noindex or canonical that a script changes
- Answer with a real 404 from a single-page app for an address that has no content
- Choose between server-side rendering, static generation and client-side rendering for pages that must be found
Before you start
On this page
Pune Bakes rebuilt its shop as a single-page app, and the HTML its server now sends for the mango mousse cake holds one word of text, no links, and a canonical that names the home page. Google sees the cake only after it has run the page’s script, and a crawler that runs no scripts never sees it at all.
Scripts are not a problem for Google Search in themselves, but they move a page’s content out of the server’s answer and into a second step. By the end of this page you can explain how Googlebot crawls, renders and indexes a JavaScript page, compare a page’s server HTML with its rendered HTML, fix the traps that hide content or send the wrong signal, make a single-page app answer 404 for a missing page, and choose how to build the pages that must be found.
Google crawls, then renders, then indexes
Google processes a JavaScript page in three phases: crawling, rendering: Running a page's scripts and building the page they produce, as a browser does. Google renders pages that answer 200 in a step after crawling, with an evergreen Chromium, and indexes the rendered HTML. and indexing (JavaScript SEO basics). Googlebot reads robots.txt, fetches the page and reads the links in the HTML it received. Every page that answers 200 then waits in a render queue, unless a robots meta tag or header says not to index it, and a headless, evergreen version of Chromium runs its scripts; Google reads the links of the rendered HTML again and indexes what that HTML shows. Two cases change the path: for a page that answers 404 or another code than 200, “rendering might be skipped”, and when the HTML Google received says noindex, it “may skip rendering and JavaScript execution”. Step through the bakery’s app shell:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Crawl. Googlebot reads robots.txt, then fetches /cakes/mango-mousse and gets the app shell with status 200. A page or a script that robots.txt blocks is not fetched, and so never rendered.
- Links in the HTML. Google reads the href of every a element in the HTML it received and queues those addresses. The shell has none; a page rendered on the server would hand over all its links here.
- The render queue. Every page that answers 200 goes into the render queue, unless a robots meta tag or header says not to index it. It may wait there for seconds, or longer.
- When rendering is skipped. For a page that answers 404 or another code than 200, Google may skip rendering, and it may skip it too when the HTML it received says noindex. Then only that HTML counts.
- Render. A headless, evergreen version of Chromium runs the page's scripts. It keeps no cookies or local storage between pages, clicks nothing and scrolls nothing, so content that waits for a person never appears.
- Index, and links again. Google reads the rendered HTML for links once more, queues the new addresses, and indexes what the rendered page shows. The cake's text, its canonical and its three links reach Google only now.
The renderer is not a person at a browser. It keeps no cookies or local storage between pages, cannot use WebSockets or WebRTC, and declines permission requests such as the camera (Fix Search-related JavaScript problems), and Google Search does not click or scroll (lazy-loaded content). Google has also removed what it calls “outdated information about JavaScript and accessibility” from its guides (documentation updates): its renderer handles modern scripts, and the cost of a JavaScript page is the wait and the second step, not a refusal.
Key idea
Google indexes the rendered HTML of a page that answers 200 and does not say noindex; for any other page it may use only the HTML the server sent, and for a crawler that runs no scripts that HTML is all there is.
Checkpoint
Check: Crawl, render, index
Compare the server’s HTML with the rendered HTML
Google’s own guide says that server-side or pre-rendering is “still a great idea”, partly because “not all bots can run JavaScript” (JavaScript SEO basics), and other search engines “may choose to ignore JavaScript” (dynamic rendering). So what a page must say to every reader of its HTML, its title, description, canonical, robots rules, the Open Graph tags that link previews read, its main text and its links, belongs in the HTML the server sends. The bakery’s two versions of the cake page show the difference. The app shell, as the server sends it:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pune Bakes</title>
<link rel="canonical" href="https://punebakes.example/">
<script type="module" src="/app.js"></script>
</head>
<body>
<div id="app">Loading…</div>
</body>
</html> and the script that draws the cake into it in the browser:
// The bakery's single-page app: it reads the address, fetches the cake and draws the page in the browser.
const slug = location.pathname.split("/").pop();
const response = await fetch(`/api/cakes/${slug}.json`);
const app = document.querySelector("#app");
if (!response.ok) {
app.innerHTML = "<h1>Cake not found</h1><p>Try one of our other cakes.</p>";
} else {
const cake = await response.json();
document.title = `${cake.name} | Pune Bakes`;
document.head.insertAdjacentHTML(
"beforeend",
`<meta name="description" content="${cake.summary}">` +
`<link rel="canonical" href="https://punebakes.example/cakes/${slug}">` +
`<meta property="og:title" content="${cake.name}">` +
`<script type="application/ld+json">${JSON.stringify({ "@context": "https://schema.org", "@type": "Product", name: cake.name })}</script>`,
);
app.innerHTML =
`<h1>${cake.name}</h1><p>${cake.description}</p>` +
cake.related.map((r) => `<a href="/cakes/${r.slug}">${r.name}</a>`).join("") +
`<a href="/cakes/">All cakes</a>` +
`<button onclick="showReviews()">Show ${cake.reviews} reviews</button>`;
} Rendering runs a page’s scripts and keeps the HTML they leave. The program below does that for app.js, with a small
stand-in for the browser that supports exactly the calls the script makes, then reads the same nine items from the HTML
the server sends and from the HTML after the script, for the shell and for cake.html, the version rendered on the
server:
// What each crawler sees of two versions of Pune Bakes' mango mousse page: cake.html, which the server renders, and
// shell.html, an app shell whose script app.js fetches the cake and draws the page in the browser.
//
// Rendering runs a page's scripts and keeps the HTML they leave. This program does that for app.js with a small
// stand-in for the browser: a document that supports exactly the calls the script makes (document.title,
// head.insertAdjacentHTML, querySelector("#app").innerHTML) and a fetch that answers from mango-mousse.json. It then
// reads the same items from the HTML the server sends and from the HTML after the script, and compares them (the
// canonicals without their origin, https://punebakes.example).
import { readFileSync } from "node:fs";
const read = (file) => readFileSync(new URL(file, import.meta.url), "utf8");
async function render(shell, script, path) {
let title = /<title>(.*?)<\/title>/s.exec(shell)[1];
let head = "";
let app = /<div id="app">(.*?)<\/div>/s.exec(shell)[1];
const document = {
get title() { return title; },
set title(value) { title = value; },
head: { insertAdjacentHTML: (where, html) => { head += html; } },
querySelector: (selector) => (selector === "#app" ? { set innerHTML(html) { app = html; } } : null),
};
const fetch = async (url) => {
const found = url === "/api/cakes/mango-mousse.json";
return { ok: found, status: found ? 200 : 404, json: async () => JSON.parse(read("./mango-mousse.json")) };
};
const AsyncFunction = (async () => {}).constructor;
await new AsyncFunction("document", "location", "fetch", script)(document, { pathname: path }, fetch);
return shell
.replace(/<title>.*?<\/title>/s, `<title>${title}</title>`)
.replace("</head>", `${head}</head>`)
.replace(/<div id="app">.*?<\/div>/s, `<div id="app">${app}</div>`);
}
function items(html) {
const all = (re) => [...html.matchAll(re)].map((m) => m[1]);
const body = /<body>(.*)<\/body>/s.exec(html)[1];
return {
title: all(/<title>(.*?)<\/title>/gs)[0],
description: all(/<meta name="description" content="(.*?)">/g)[0] ?? "none",
canonical: all(/<link rel="canonical" href="https:\/\/punebakes\.example(.*?)">/g).join(" and ") || "none",
"og:title": all(/<meta property="og:title" content="(.*?)">/g)[0] ?? "none",
h1: all(/<h1>(.*?)<\/h1>/g)[0] ?? "none",
links: all(/<a href="(.*?)">/g).length,
words: body.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean).length,
"structured data": all(/"@type": ?"(\w+)"/g)[0] ?? "none",
"behind a click": all(/<button[^>]*>(.*?)<\/button>/g)[0] ?? "nothing",
};
}
const short = (value) => (String(value).split(" ").length > 7 ? `${String(value).split(" ").slice(0, 6).join(" ")} …` : value);
function compare(name, before, after) {
console.log(name);
for (const [key, value] of Object.entries(before)) {
console.log(` ${key}: ${short(value)}`);
if (after[key] !== value) console.log(` after the script: ${short(after[key])}`);
}
}
const cake = read("./cake.html");
compare("cake.html, rendered on the server: the same before and after scripts", items(cake), items(cake));
const shell = read("./shell.html");
const before = items(shell);
const after = items(await render(shell, read("./app.js"), "/cakes/mango-mousse"));
console.log();
compare("shell.html with app.js: what the server sends, then what the script adds", before, after);
const unseen = Object.keys(before).filter((key) => key !== "behind a click" && before[key] !== after[key]);
const reviews = JSON.parse(read("./mango-mousse.json")).reviews;
console.log(`\nA crawler that runs no scripts gets none of the ${unseen.length} items the script adds or changes,`);
console.log(`and reads ${before.canonical} as the canonical. The ${reviews} reviews load only after a click,`);
console.log("so no crawler reads them."); Output
cake.html, rendered on the server: the same before and after scripts
title: Mango mousse cake | Pune Bakes
description: Eggless mango mousse cake made with …
canonical: /cakes/mango-mousse
og:title: Mango mousse cake
h1: Mango mousse cake
links: 3
words: 31
structured data: Product
behind a click: nothing
shell.html with app.js: what the server sends, then what the script adds
title: Pune Bakes
after the script: Mango mousse cake | Pune Bakes
description: none
after the script: Eggless mango mousse cake made with …
canonical: /
after the script: / and /cakes/mango-mousse
og:title: none
after the script: Mango mousse cake
h1: none
after the script: Mango mousse cake
links: 0
after the script: 3
words: 1
after the script: 34
structured data: none
after the script: Product
behind a click: nothing
after the script: Show 48 reviews
A crawler that runs no scripts gets none of the 8 items the script adds or changes,
and reads / as the canonical. The 48 reviews load only after a click,
so no crawler reads them.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node render_diff.mjs
The server-rendered page says everything before any script runs. The shell says almost nothing: its title is “Pune Bakes”, it has one word of text and no links, and its canonical names the home page, so a crawler that runs no scripts reads a wrong canonical as well as missing content. After the script, the shell has all eight items, but now two canonicals, the home page’s and the cake’s, and the 48 reviews still wait behind a button that no crawler presses.
On your own site, compare the same two versions. The raw HTML is what the server sends, as a browser’s view source or a fetch shows it. The rendered HTML is in URL Inspection’s live test: “View tested page” shows the HTML, a screenshot, the JavaScript console output and the resources the page loaded; the screenshot exists only in a live test that succeeded (URL Inspection tool).
Indexability Checker See what one of your own addresses sends before any script runs: its status, headers, robots rules and canonical. HAR File Analyzer & Sanitizer Open a HAR file saved from your browser's developer tools and see every request a page made, with its status, size and timing.Checkpoint
Check: The server's HTML and the rendered HTML
Five traps that hide content or send the wrong signal
Most JavaScript SEO problems are one of five traps, and each looks fine in a browser:
| Trap | What Google gets | The fix |
|---|---|---|
Links without an href: onclick, router links, spans |
No link to follow; Google finds links only in <a> elements with an href |
<a href="/cakes/rasmalai-cake">, with the script on top |
| Content that waits for a click or a scroll | Nothing: Google does not click or scroll | Load what is in view when it comes into view; give each chunk of an endless list its own address |
Views in fragments, /#/cakes |
Addresses Google cannot reliably resolve | Real paths, changed with the History API: The browser interface, history.pushState among it, that lets a single-page app show a real path in the address bar as its view changes, instead of a # fragment. |
| A noindex in the HTML that a script removes | Rendering may be skipped, so the noindex stays | No noindex in the HTML of a page that should be indexed |
| A canonical that a script adds or changes | Two canonicals, or one that disagrees with the HTML | The canonical in the HTML; a script sets the same value, or the HTML leaves it out |
The first three come from Google’s guides to links, lazy loading and JavaScript (link best practices); the noindex trap was the subject of a section of the lesson on indexing controls; the canonical trap is the one the render comparison found. Google’s rule for it is that HTML is the best place for the canonical, and a script that sets it must use “the same value as the original HTML” (JavaScript SEO basics; specify a canonical).
A canonical the script adds as a second one
The bakery’s shell names the home page as canonical, and its script adds the cake’s own canonical, so the rendered page has two that disagree. Send the cake’s canonical in the HTML of each cake address, or leave the canonical out of the shell and let the script add the only one.
Three more rules round it off. Shadow DOM is flattened in rendering, so only what reaches the rendered HTML, through a
slot for example, can be indexed. Scripts and styles should carry a fingerprint of their content in their names,
such as main.2bb85551.js, because Google caches them aggressively. And a paywall that sends the whole article and
hides it with JavaScript “isn’t a reliable way to limit access to the content”: the article is in the HTML anyone
downloads, so the server should send the full text only once the subscription is confirmed
(Fix Search-related JavaScript problems),
and the paywalled parts are marked up so that Google can tell them from cloaking
(paywalled content).
Checkpoint
Check: Traps that hide content or mislead
A single-page app must still answer 404 for a missing page
A single-page app usually sends the same app shell: The near-empty HTML that a client-side app sends for every address: a title, a script and an empty element that the script fills in the browser. for every address, so its server answers 200 for every address, real or not, and the script draws “Cake not found” in the browser. Google asks for “a meaningful status code”, 404 for a page that does not exist (JavaScript SEO basics), and warns that apps which handle errors in the browser often report 200 instead, which can get error pages indexed and shown in results (Fix Search-related JavaScript problems). Step through a visit to a cake the bakery no longer makes:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- An old address. Googlebot still knows /cakes/blueberry-cheesecake from links on other sites, although the bakery stopped making the cake.
- The shell, with 200. The server sends the same app shell for every address, with status 200, because only the script knows which cakes exist.
- The script says not found. In rendering, the script asks the bakery's API for the cake, gets a 404 and draws "Cake not found". The status code still says 200.
- A soft 404. A page that says not found while its status says success is a soft 404. It can be indexed as a page, and it keeps being crawled.
- The fix on the server. The handler looks the cake up before it answers, and sends 404 with a small not-found page for an address that has no cake.
- When the server cannot know. If only the script can tell, Google gives two ways out. The script sends the browser to an address that answers 404, such as /not-found, or it adds a robots meta tag with noindex to the error view.
The fix on the server is a lookup before the answer. The program below calls two versions of the bakery’s request handler for three addresses, the way the web server calls it for each request:
// Two versions of the request handler behind Pune Bakes' single-page app, called directly for three addresses, the
// way the web server calls it for each request. Before: every address gets the app shell with status 200, and the
// script then draws "Cake not found" in the browser. After: the handler looks the cake up first, and answers 404 with
// a small page of its own when the cake does not exist, as Google's JavaScript guide asks of a missing page.
const CAKES = new Set(["mango-mousse", "eggless-truffle", "rasmalai-cake"]); // the cakes the shop sells today
const SHELL = '<!doctype html><title>Pune Bakes</title><div id="app">Loading…</div>';
const NOT_FOUND = '<!doctype html><title>Cake not found | Pune Bakes</title><h1>Cake not found</h1>';
function before(path) {
return { status: 200, body: SHELL };
}
function after(path) {
const slug = /^\/cakes\/([a-z0-9-]+)$/.exec(path)?.[1];
if (slug && !CAKES.has(slug)) return { status: 404, body: NOT_FOUND };
return { status: 200, body: SHELL };
}
function shown(path, answer) {
if (answer.status === 404) return "the not-found page";
const known = CAKES.has(path.split("/").pop());
return known ? "the script draws the cake" : 'the script draws "Cake not found": a soft 404';
}
const PATHS = ["/cakes/mango-mousse", "/cakes/blueberry-cheesecake", "/cakes/plum-pudding"];
for (const [name, handle] of [["Before: the app shell for every address", before], ["After: a lookup first", after]]) {
console.log(name);
for (const path of PATHS) {
const answer = handle(path);
console.log(` ${path.padEnd(28)} ${answer.status} ${shown(path, answer)}`);
}
} Output
Before: the app shell for every address /cakes/mango-mousse 200 the script draws the cake /cakes/blueberry-cheesecake 200 the script draws "Cake not found": a soft 404 /cakes/plum-pudding 200 the script draws "Cake not found": a soft 404 After: a lookup first /cakes/mango-mousse 200 the script draws the cake /cakes/blueberry-cheesecake 404 the not-found page /cakes/plum-pudding 404 the not-found page
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node handler.mjs
Before, all three addresses answer 200 and two of them are soft 404s. After, the handler answers 404 for the two cakes
the shop does not sell, and the mango mousse cake is unchanged. When only the script can tell whether a page exists,
Google offers two ways out: the script sends the browser to an address that answers 404, such as /not-found, or it
adds a robots meta tag with noindex to the error view.
Checkpoint
Check: Status codes in a single-page app
Render on the server what must be found
Four ways of building a page come up in every choice, in web.dev’s words (Rendering on the Web):
- server-side rendering: Building a page's HTML on the server for each request, so that every visitor and crawler receives the content without having to run scripts. builds the page on the server for each request, so that the browser receives “HTML, rather than JavaScript”.
- static generation: Building the HTML of every address ahead of time, when the site is built, one file per address, so that the server only has to send files. builds the HTML of every address ahead of time, at build time, one file per address.
- client-side rendering: Building a page in the browser with JavaScript, from an app shell and data the script fetches. Only crawlers that run scripts see its content. builds the page “in a browser, using JavaScript to modify the DOM”, from an app shell.
- hydration: Running scripts in the browser to make HTML that the server built interactive, without building the page again. runs scripts in the browser “to add application state and interactivity to server-rendered HTML”.
A fifth way, dynamic rendering: Sending crawlers a server-rendered copy of a page and people its client-side version. Google calls it a workaround and not a recommended solution., sends crawlers a server-rendered copy and people the app; Google calls it “a workaround and not a recommended solution”, and recommends server-side rendering, static rendering or hydration instead (dynamic rendering). Two questions decide most cases. Answer them for a page of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way of building | Every crawler sees it | Fresh at each request | Fast first paint | Little to run |
|---|---|---|---|---|
| Static generation The browser gets: HTML built ahead of time | Good the HTML holds the whole page | Mixed only as fresh as the last build | Good a fast first byte and a fast first paint | Good files that any server can send |
| Server-side rendering The browser gets: HTML built for each request | Good the HTML holds the whole page | Good built again for every request | Mixed a fast first paint, after a slower first byte | Mixed a server does the work of every page view |
| Server HTML with hydration The browser gets: server HTML, then scripts | Good the HTML holds the page before any script runs | Good built for each request, or at the last build | Mixed a fast first paint, but heavy scripts can delay input | Mixed the app runs twice: on the server, then again in the browser |
| Client-side rendering The browser gets: an app shell and its scripts | Poor only crawlers that run scripts, after a wait in a queue | Good data fetched in the browser at each view | Mixed the page waits for its scripts, slower as the app grows | Good one shell for every address |
| Dynamic rendering The browser gets: HTML for bots, scripts for people | Mixed bots get HTML, but Google calls it a workaround | Mixed as fresh as the rendering server | Mixed people still wait for the app | Poor two versions to run and keep alike |
How the answers decide
- “Must the page be found through search and look right when its link is shared?” Yes, like a product or an article: rules out Client-side rendering and adds a caveat to Dynamic rendering. What must be found belongs in the HTML the server sends, because not all bots can run scripts and Google renders later. Dynamic rendering works, but Google calls it a workaround.
- “Must the page be found through search and look right when its link is shared?” No, like a dashboard behind a login: adds a caveat to Dynamic rendering. A page that is not crawled does not need HTML for bots; choose for speed and freshness, and keep the address answering a real status code.
- “How often does what the page shows change?” Only when the site publishes: adds a caveat to Server-side rendering and Server HTML with hydration. A page that changes only when you publish can be built ahead of time, with no work on the server for each view.
- “How often does what the page shows change?” For each visitor, or each minute: rules out Static generation. What differs for each visitor or each minute cannot be built ahead of time.
In short: build product, article and category pages ahead of time or on the server, so that every crawler gets their content, and add scripts on top for what people do on the page; keep client-side rendering for what is never searched for, such as an account’s dashboard.
Checkpoint
Check: Choosing how to build a page
Practise: compare five pages before and after their scripts
Spice Box has five pages, each as the HTML its server sends and as the HTML after its script ran. Write seo_view()
with Python’s HTML parser, then list what only the scripts add and say which version Google can be expected to index;
the sample tests run your code on all five:
Exercise · Medium · Python
Compare five pages before and after their scripts
PAGES in seo_view.py holds five Spice Box pages, each with its status code and two versions of its HTML: raw, what the server sends, and rendered, the HTML after the page's script ran.
- Write
seo_view(html)with Python'shtml.parser, so that it returns what one version says to a crawler: itstitle, its metadescription, itscanonicals(every canonical link, in order),noindex(whether a robots meta tag says noindex), the text of its firsth1, and itslinks: thehrefof every<a>element, in order, leaving outjavascript:hrefs. Missing items areNone, or an empty list. - Write
only_after_scripts(name)so that it returns, sorted, the keys whose values differ between a page's raw and rendered HTML. - Write
google_indexes(name):"rendered"for a page with status 200 and no noindex in its raw HTML, otherwise"raw", since Google may skip rendering such a page.
The sample tests read the server-rendered page, list what the app shell's script adds, check the noindex that a script removes too late, the listing whose items are spans, and the missing spice that answers 200.
Starter code · seo_view.py
"""Five Spice Box pages, each as the HTML its server sends (raw) and as the HTML after its script ran (rendered)."""
from html.parser import HTMLParser
SHELL = '<html><head><title>Spice Box</title><link rel="canonical" href="https://spicebox.example/"></head>' \
'<body><div id="app"></div></body></html>'
PAGES = {
"saffron": { # rendered on the server: the script changes nothing
"status": 200,
"raw": '<html><head><title>Saffron threads | Spice Box</title>'
'<meta name="description" content="Kashmiri saffron threads, one gram.">'
'<link rel="stylesheet" href="/styles.2bb85551.css">'
'<link rel="canonical" href="https://spicebox.example/spices/saffron"></head>'
'<body><h1>Saffron threads</h1><a href="/spices/">Spices</a> <a href="/recipes/saffron-kheer">'
'Saffron kheer</a></body></html>',
},
"cardamom": { # an app shell: the script draws the whole page
"status": 200,
"raw": SHELL,
"rendered": '<html><head><title>Green cardamom | Spice Box</title>'
'<link rel="canonical" href="https://spicebox.example/">'
'<meta name="description" content="Green cardamom pods from Idukki.">'
'<link rel="canonical" href="https://spicebox.example/spices/green-cardamom"></head>'
'<body><div id="app"><h1>Green cardamom</h1><a href="/spices/">Spices</a> '
'<a href="/recipes/masala-chai">Masala chai</a> <a href="/spices/black-cardamom">Black cardamom</a>'
'</div></body></html>',
},
"offers": { # noindex in the HTML the server sends, removed by the script once the offers load
"status": 200,
"raw": '<html><head><title>Offers | Spice Box</title><meta name="robots" content="noindex"></head>'
'<body><div id="app"></div></body></html>',
"rendered": '<html><head><title>Offers | Spice Box</title></head>'
'<body><div id="app"><h1>This week\'s offers</h1><a href="/blends/garam-masala">Garam masala</a>'
'</div></body></html>',
},
"long-pepper": { # a product that no longer exists, answered with the app shell and status 200
"status": 200,
"raw": SHELL,
"rendered": '<html><head><title>Spice Box</title><link rel="canonical" href="https://spicebox.example/"></head>'
'<body><div id="app"><h1>Spice not found</h1><a href="/spices/">Spices</a></div></body></html>',
},
"blends": { # a listing whose script draws its items as spans with onclick
"status": 200,
"raw": SHELL,
"rendered": '<html><head><title>Blends | Spice Box</title>'
'<link rel="canonical" href="https://spicebox.example/"></head>'
'<body><div id="app"><h1>Blends</h1>'
'<span onclick="go(\'/blends/garam-masala\')">Garam masala</span> '
'<span onclick="go(\'/blends/chai-masala\')">Chai masala</span> '
'<a href="/">Home</a></div></body></html>',
},
}
PAGES["saffron"]["rendered"] = PAGES["saffron"]["raw"]
def seo_view(html):
"""What one version of a page says to a crawler: a dict with the keys title, description, canonicals (a list),
noindex (True or False), h1 and links (the href of every a element, in order; javascript: hrefs left out)."""
return {"title": None, "description": None, "canonicals": [], "noindex": False, "h1": None, "links": []}
def only_after_scripts(name):
"""The keys of seo_view whose values differ between the page's raw and rendered HTML, sorted."""
return []
def google_indexes(name):
"""Which version Google can be expected to index: "rendered" for a 200 page without noindex in its raw HTML;
otherwise "raw", because Google may skip rendering a page whose status is not 200 or whose raw HTML says noindex."""
return "rendered" The sample tests · test_seo_view.py
from seo_view import PAGES, google_indexes, only_after_scripts, seo_view
def test_server_rendered_page():
"""reads the saffron page: title, description, one canonical (not the stylesheet), no noindex, h1, two links"""
assert seo_view(PAGES["saffron"]["raw"]) == {
"title": "Saffron threads | Spice Box",
"description": "Kashmiri saffron threads, one gram.",
"canonicals": ["https://spicebox.example/spices/saffron"],
"noindex": False,
"h1": "Saffron threads",
"links": ["/spices/", "/recipes/saffron-kheer"],
}
def test_what_the_app_shell_adds():
"""the cardamom page's script adds everything but the home canonical, which stays as a second canonical"""
assert only_after_scripts("cardamom") == ["canonicals", "description", "h1", "links", "title"]
assert seo_view(PAGES["cardamom"]["rendered"])["canonicals"] == [
"https://spicebox.example/", "https://spicebox.example/spices/green-cardamom"]
assert only_after_scripts("saffron") == []
def test_noindex_in_the_raw_html():
"""the offers page carries noindex in its raw HTML, so Google may index the raw version, with its noindex"""
assert seo_view(PAGES["offers"]["raw"])["noindex"] is True
assert seo_view(PAGES["offers"]["rendered"])["noindex"] is False
assert google_indexes("offers") == "raw"
assert google_indexes("cardamom") == "rendered"
def test_spans_are_not_links():
"""the blends listing's items are spans with onclick: after its script, the only link is Home"""
assert seo_view(PAGES["blends"]["rendered"])["links"] == ["/"]
def test_missing_spice_answers_200():
"""the long pepper page says Spice not found while its status says 200: a soft 404 for Google"""
page = PAGES["long-pepper"]
assert page["status"] == 200 and seo_view(page["rendered"])["h1"] == "Spice not found"
assert "h1" in only_after_scripts("long-pepper") A hint
Subclass HTMLParser and fill a dict as the tags arrive: handle_starttag(tag, attrs) gets the attributes as a list of pairs, so dict(attrs) gives you rel, href, name and content. For title and h1, remember that you are inside the tag in handle_starttag and keep the text that handle_data receives until handle_endtag.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise the five ideas of this lesson, starting with your weakest:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): does Google index content that JavaScript adds to a page?
Yes, usually, but later and with conditions. Google crawls the page, queues it for rendering, runs its scripts in an evergreen version of Chromium and indexes the rendered HTML, so text, links and tags that a script adds can be indexed. Three conditions apply. The page must answer 200 and must not say noindex in the HTML the server sends, or Google may skip rendering. The content must appear without a click or a scroll, because Google does not interact with the page. And the scripts must not be blocked by robots.txt. Other crawlers, some search engines among them, may not run scripts at all, so what every reader of the page needs, the title, the description, the canonical, the main text and the links, is safest in the HTML the server sends.
Two more questions follow, about a shop rebuilt as an app whose pages stopped being indexed and a paywall that hides articles with a script. Make the first decision of each before you open it:
Checkpoint
Your first decision
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Three months after a shop was rebuilt as a client-side app, many product pages are "Crawled - currently not indexed", some are soft 404s, and its links shared in chat apps show only the shop's name. How do you find out what is wrong, and what do you fix?
-
Mid to senior
A news site sends every article in full and hides the text with JavaScript until the reader is confirmed as a subscriber. What is wrong with this paywall, and what do you change, for readers and for Google?
Key takeaways
- Google crawls, renders and indexes: a page that answers 200 without a noindex is rendered in an evergreen Chromium and indexed as rendered, while other pages, and crawlers that run no scripts, get only the server’s HTML.
- Compare the HTML the server sends with the rendered HTML from URL Inspection, and put what every reader needs, the title, description, canonical, main text and links, in the server’s HTML.
- The traps are links without
href, content behind a click or a scroll, fragment routes, a noindex a script removes and a canonical a script changes. - A single-page app answers 404 for a missing page from the server, or sends the browser to an address that answers 404, or adds noindex to its error view.
- Pages that must be found are built ahead of time or on the server, with scripts on top; dynamic rendering is a workaround, and client-side rendering suits pages nobody searches for.
Terms in this lesson
- app shell
- The near-empty HTML that a client-side app sends for every address: a title, a script and an empty element that the script fills in the browser.
- client-side rendering
- Building a page in the browser with JavaScript, from an app shell and data the script fetches. Only crawlers that run scripts see its content.
- dynamic rendering
- Sending crawlers a server-rendered copy of a page and people its client-side version. Google calls it a workaround and not a recommended solution.
- History API
- The browser interface, history.pushState among it, that lets a single-page app show a real path in the address bar as its view changes, instead of a # fragment.
- hydration
- Running scripts in the browser to make HTML that the server built interactive, without building the page again.
- rendering
- Running a page's scripts and building the page they produce, as a browser does. Google renders pages that answer 200 in a step after crawling, with an evergreen Chromium, and indexes the rendered HTML.
- server-side rendering
- Building a page's HTML on the server for each request, so that every visitor and crawler receives the content without having to run scripts.
- static generation
- Building the HTML of every address ahead of time, when the site is built, one file per address, so that the server only has to send files.
References
- Understand the JavaScript SEO basics (Google Search Central)
- Fix Search-related JavaScript problems (Google Search Central)
- Fix lazy-loaded content (Google Search Central)
- Dynamic rendering as a workaround (Google Search Central)
- Link best practices for Google (Google Search Central)
- How to specify a canonical URL with rel="canonical" and other methods (Google Search Central)
- Structured data for subscription and paywalled content (Google Search Central)
- Latest Google Search documentation updates (Google Search Central)
- URL Inspection tool (Google Search Console Help)
- Rendering on the Web (web.dev (Google))
- The Open Graph protocol (Open Graph protocol)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress