Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · stage by stage Crawl, render, index Step through what Google does with the bakery's app shell for the mango mousse cake, and where rendering can be skipped.
Crawl, render, index, step 6 of 6. Showing Crawl the HTML (200, the app shell), Read its links (none in the shell), Wait to be rendered, Render the page (title and cake drawn), Index the rendered HTML. In focus: Index the rendered HTML.rendering may be skippedCrawl the HTML200, the app shellRead its linksnone in the shellWait to be renderedseconds, or longerRender the pagetitle and cake drawnIndex the rendered HTMLlinks read again

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 Which pages does Google put into its render queue?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Pages that answer 200, unless a robots meta tag or header says not to index them

    A page that answers 200 and does not say noindex is rendered, and its rendered HTML is what Google indexes.

  2. Question 2 of 2 An offers page sends noindex in its HTML, and its script removes the tag once the offers load. What should you expect?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Google may skip rendering, so the noindex stays and the page is left out

    A page that should be indexed carries no noindex in the HTML the server sends, whatever a script does later.

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:

shell.html: what the server sends for every cake HTML · render/shell.html
<!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:

app.js: the single-page app's script JavaScript · render/app.js
// 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:

Nine items, before and after the script JavaScript · render/render_diff.mjs
// 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

  1. Question 1 of 2 In the render comparison, which address does a crawler that runs no scripts read as the canonical of the app shell?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The home page, /

    The shell sends a wrong signal as well as missing content, which is worse than sending nothing.

  2. Question 2 of 2 Where can you see the rendered HTML that Google gets for one of your pages?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In URL Inspection's live test, under View tested page

    Compare it with the raw HTML from view source or a fetch: the difference is what only scripts add.

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

  1. Question 1 of 2 The app shows each cake at an address such as /#/cakes/mango-mousse. What should change?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Real paths such as /cakes/mango-mousse, changed with the History API

    With real paths each cake has an address of its own, which links, sitemaps and canonicals can name.

  2. Question 2 of 2 The cake's 48 reviews load only when a visitor presses "Show 48 reviews". Does Google index them?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No; Google does not click, so the reviews never reach the rendered HTML

    Send the reviews with the page, or load them when they come into view, without waiting for a click.

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:

Interactive · message by message A missing cake that answers 200 Step through Googlebot's visit to a cake the bakery no longer makes, first as the app answers it today, then fixed.
A missing cake that answers 200, step 6 of 6. Messages so far: Googlebot to The bakery's server: GET blueberry-cheesecake; The bakery's server to Googlebot: 200, the app shell; The bakery's server to Googlebot: 404, not found.GooglebotThe bakery's server404 for a missing cakeGET blueberry-cheesecake200, the app shell404, not found

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. An old address. Googlebot still knows /cakes/blueberry-cheesecake from links on other sites, although the bakery stopped making the cake.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

The app's handler, before and after a lookup JavaScript · spa404/handler.mjs
// 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

  1. Question 1 of 2 What should the address of a cake that does not exist answer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 404, from the server

    The fixed handler looked the cake up first and answered 404 for the two cakes the shop does not sell.

  2. Question 2 of 2 The bakery's server cannot tell which cakes exist; only the script knows. What are Google's two ways out?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The script sends the browser to an address that answers 404, or adds a robots noindex to the error view

    Prefer the server's 404 when it can know; use the script's ways out only when it cannot.

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:

Interactive · answer and compare How should this page be built? Answer two questions about one page of your site. The table marks the ways of building it that fit, and says why the others do not.
Must the page be found through search and look right when its link is shared?
How often does what the page shows change?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways of building a page against crawlers, freshness, the first paint and the work of running it
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

  1. Question 1 of 2 The bakery's product pages must be found in search and change only when the bakery publishes. Which way of building fits best?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Static generation, with scripts added on top for what people do on the page

    Server-side rendering would also fit; static generation adds no work on the server for each view.

  2. Question 2 of 2 What does Google call dynamic rendering, which sends bots a server-rendered page and people the app?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A workaround and not a recommended solution

    It adds a second version to build, run and keep alike, which is why Google points elsewhere.

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.

  1. Write seo_view(html) with Python's html.parser, so that it returns what one version says to a crawler: its title, its meta description, its canonicals (every canonical link, in order), noindex (whether a robots meta tag says noindex), the text of its first h1, and its links: the href of every <a> element, in order, leaving out javascript: hrefs. Missing items are None, or an empty list.
  2. Write only_after_scripts(name) so that it returns, sorted, the keys whose values differ between a page's raw and rendered HTML.
  3. 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.

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.

  1. Question 1 of 15 In which order does Google process a JavaScript page?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Crawling, rendering, indexing

    Links are read twice: in the HTML that was crawled, and again in the rendered HTML.

  2. Question 2 of 15 The bakery's robots.txt blocks /app.js. What does Google's rendering of a cake page show?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Only what the shell holds, because Google cannot fetch the blocked script

    Never block the scripts and styles a page needs to show its content.

  3. Question 3 of 15 The app shows a price only after reading the visitor's city from local storage, written on a previous page. What does Google's renderer see?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: No price, because the renderer keeps no local storage between pages

    Show content that matters without depending on stored state, or with a default that the page itself sets.

  4. Question 4 of 15 What is a page's raw HTML?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: The HTML the server sends, before any script runs

    Read it with view source or a fetch, and compare it with the rendered HTML of URL Inspection's live test.

  5. Question 5 of 15 Which of these belong in the HTML the server sends, not only in what a script adds?

    Choose every answer that is right.

    Show the answer: Question 5 of 15

    Answer:

    • The canonical link
    • The title and the meta description
    • The Open Graph tags

    What every reader needs goes in the server's HTML; what people do on the page can come with the script.

  6. Question 6 of 15 In the render comparison, how many words of text does the shell's body hold before the script runs?

    Type a number.

    A hint

    Read the shell's div before the script fills it.

    Show the answer: Question 6 of 15

    Answer: 1 word

    Only "Loading…": one word, against 34 after the script and 31 in the server-rendered page.

  7. Question 7 of 15 Which of these lines of a single-page app's menu can Google follow?

    Read the code, then choose one answer.

    <!-- A --> <a href="/cakes/">Cakes</a>
    <!-- B --> <a routerLink="/cakes/">Cakes</a>
    <!-- C --> <span onclick="router.go('/cakes/')">Cakes</span>
    <!-- D --> <a href="#/cakes/">Cakes</a>
    Show the answer: Question 7 of 15

    Answer: A

    A router can still handle the click; the href is what gives Google an address.

  8. Question 8 of 15 The shell names the home page as canonical, and the script adds the cake's canonical. What is the fix?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Send the cake's canonical in the HTML of each cake address, or leave it out of the shell and let the script add the only one

    The comparison found exactly this: "/" and "/cakes/mango-mousse" after the script.

  9. Question 9 of 15 The bakery changes app.js but keeps its file name, and Google's renders still show the old behaviour. Why, and what fixes it?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Google caches scripts aggressively and may ignore cache headers; put a hash of the content in the file name

    A fingerprinted name also lets browsers keep each version for a long time.

  10. Question 10 of 15 Why is a single-page app's "Cake not found" view with status 200 a soft 404?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: The page says the content is missing while its status says success

    Before the fix, two of the handler's three addresses answered this way.

  11. Question 11 of 15 In the handler program, how many of the three addresses answer 404 after the fix?

    Type a number.

    A hint

    Which of the three cakes does the shop still sell?

    Show the answer: Question 11 of 15

    Answer: 2 addresses

    The blueberry cheesecake and the plum pudding are not in CAKES, so both answer 404; the mango mousse cake answers 200.

  12. Question 12 of 15 A developer chooses the noindex way out for the app's error view. Where should the noindex appear?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Only in the error view, added by the script; never in the shell that every address shares

    Google's example assumes the HTML has no robots meta tag yet; the script adds one only when the view is an error.

  13. Question 13 of 15 What is server-side rendering?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Building the page's HTML on the server, so that the browser receives HTML rather than JavaScript to build it

    Server-side rendering builds the HTML for each request; static generation builds it at build time.

  14. Question 14 of 15 An account dashboard sits behind a login, and no one searches for it. How may it be built?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: Client-side rendering is fine; choose for speed and freshness

    The rendering choice matters for pages that must be found; elsewhere it is about the people who use the page.

  15. Question 15 of 15 What does web.dev say is the main cost of server-side rendering with hydration?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: It can hurt how fast the page responds to input, even while it speeds up the first paint

    Keep the scripts that hydrate a page small, and add them only where the page needs them.

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

  1. Question 1 of 2 Interview brief: three months after a shop was rebuilt as a client-side app, many product pages are not indexed and some are soft 404s. What do you look at first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The raw HTML and the rendered HTML of a sample of products, from a fetch and from URL Inspection's live test

    Then check the status codes of missing products, the canonical in the shell, links without href and content behind clicks.

  2. Question 2 of 2 Interview brief: a news site sends every article in full and hides it with JavaScript until the reader signs in as a subscriber. What do you say first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The article is in the HTML anyone downloads, so the server should send the full text only to a confirmed subscriber

    Then mark the paywalled parts up with isAccessibleForFree, so that Google can tell them from cloaking.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.