Skip to content

    Frontend System Design

    SEO Architecture for Web Apps: What Crawlers Need From Your Rendering

    Learn which architecture decisions decide what a search crawler sees: rendering, status codes, canonicals, links, sitemaps, robots rules and structured data.

    By Published 12 min read

    • frontend-system-design
    • seo
    • crawling
    • canonical-urls
    Share

    Standard lessonintermediateFrontend System Design

    Before this: Rendering Strategies: CSR, SSR, SSG, ISR and Streaming

    What you'll learn: By the end of this lesson you will be able to describe how Google crawls, renders and indexes a JavaScript app, choose which pages need their content in the first HTML, return honest status codes (and avoid soft 404s), set canonical URLs, links and sitemaps correctly, tell robots.txt from noindex, add structured data that matches the page, and check all of it in the built output instead of in a browser.

    Picture a librarian who builds a catalogue from the cards in a drawer. If each card has the title and summary, the job is quick. If each card says "see the attached instructions", she may follow them later, and some librarians will not follow them at all. A search crawler is that librarian, and your architecture decides what is written on the card.

    This lesson is about what can be fetched, understood and indexed. It is not about keywords or ranking, and it promises nothing about either. It assumes Rendering Strategies: CSR, SSR, SSG, ISR and Streaming. The facts about Google come from Google Search Central documentation, cited with page dates; other search engines differ and are not covered.

    The running example is an online shop with category pages, product pages and an account area.

    The core idea

    1. A crawler needs a URL it may fetch, a response that says what the page is, and content it can read. Rendering, status codes and links are where architecture decides this.
    2. Every control is a signal with its own strength and job. A redirect, a canonical link, a sitemap entry, robots.txt and noindex each do one thing; using the wrong one is the usual mistake.
    3. Check the output, not the intention. What matters is the HTML and headers a crawler receives, which curl and a script can inspect.

    How Google processes a page

    Google's JavaScript SEO basics (last updated 2026-03-04) describe three phases: crawling, rendering and indexing. Googlebot takes a URL from its crawl queue, reads robots.txt, and if the URL is allowed fetches it and queues the links in the HTML. It "queues all pages with a 200 HTTP status code for rendering, unless a robots meta tag or header tells Google not to index the page", where the wait is "a few seconds, but it can take longer". A headless Chromium then runs the JavaScript, and the rendered HTML is parsed for links again and used for indexing.

    Three details matter. Google "won't render JavaScript from blocked files or on blocked pages". If the status is not 200, "rendering might be skipped". And: "server-side or pre-rendering is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript."

    Figure 1. From URL to index

    sequenceDiagram accTitle: How Google crawls and renders a page accDescr: Googlebot takes a URL from its crawl queue and checks robots.txt. If allowed, it fetches the page from the server. A 200 response is queued for rendering, where a headless Chromium runs the JavaScript, and the rendered HTML is parsed for links and used to index the page. participant Q as Crawl queue participant G as Googlebot participant S as Your server participant R as Render queue participant I as Index Q->>G: URL G->>S: GET /robots.txt S-->>G: rules G->>S: GET the page (if allowed) S-->>G: status, headers, HTML G->>Q: links found in the HTML G->>R: status 200: queue for rendering R->>R: headless Chromium runs the JavaScript R->>Q: links found in the rendered HTML R->>I: rendered HTML is indexed

    How to read this: the HTML you send is read twice, as fetched and after rendering. Content already in the first HTML does not wait for the render queue.

    Put the content in the first HTML

    For search the question is what a crawler gets from the first request, which the rendering strategy decides.

    Strategy First response contains For search
    Client-side rendering An app shell with no content Content exists only after rendering
    Server-side rendering Full HTML for the page Content is in the first response
    Static generation Full HTML built ahead Content is in the first response
    Incremental regeneration Full HTML, refreshed in the background Same, with freshness limits
    Streaming SSR Shell first, boundaries after Depends on what is in the shell, see Streaming SSR and Suspense

    Some teams used dynamic rendering, serving crawlers a prerendered version. Google's documentation (last updated 2025-12-10) calls it "a workaround and not a recommended solution" and recommends "server-side rendering, static rendering, or hydration".

    Illustration 1: the minimal example. The first HTML of a product page, written for a crawler:

    <head>
      <title>Trail Runner shoe | Example Shop</title>
      <meta name="description" content="A light trail shoe with a grippy sole.">
      <link rel="canonical" href="https://shop.example/products/trail-runner">
    </head>
    <body>
      <h1>Trail Runner shoe</h1>
      <a href="/category/trail-shoes">More trail shoes</a>
    </body>

    The title, canonical URL, heading and a real link are all in the HTML as sent.

    Status codes tell the truth about a page

    Google's HTTP status documentation (last updated 2026-02-04) says how each class is handled:

    Status What Google Search does
    2xx Content is considered for processing, but a 2xx "doesn't guarantee indexing". If the content looks like an error, such as an empty page or an error message, Search Console shows a soft 404.
    301 Followed, and treated as a strong signal that the target should be canonical. 302 and 303 are a weak signal.
    4xx (not 429) Not indexed; URLs already indexed are removed.
    5xx and 429 Crawlers temporarily slow down; URLs that persistently return a server error are removed from the index.

    A soft 404 is a page that says "not found" in the body but answers 200. It is the characteristic bug of apps that route on the client. Google's JavaScript guidance says that in client-rendered apps meaningful status codes "can be impossible or impractical", and offers two ways out: redirect with JavaScript to a URL for which the server answers 404 (for example /not-found), or add <meta name="robots" content="noindex"> to error pages with JavaScript. It warns that Google "may skip rendering and JavaScript execution" when it sees noindex, so removing it later with JavaScript may not work.

    The better answer is architectural: decide the status on the server, before any HTML is sent. A streamed page can do that only before streaming starts, because the status is committed with the first chunk (the trap described in the streaming lesson).

    Illustration 2: the realistic example. The shop's /products/trail-runner exists. /products/does-not-exist must answer 404 from the server with a "not found" page, and no catch-all may serve the home page for unknown paths.

    Canonical URLs and duplicates

    One page often has several URLs: tracking parameters, sort orders, www and bare domain, trailing slashes. Google's canonicalization guidance (last updated 2026-07-10) lists ways to say which is preferred, by strength: redirects are a strong signal, rel="canonical" link annotations are a strong signal, and a sitemap entry is a weak signal. A rel="canonical" HTTP header is an option when you control server configuration, including for non-HTML files such as PDFs. The rules worth keeping:

    • use absolute URLs in rel="canonical";
    • put a self-referencing canonical on the canonical page itself;
    • do not give different canonicals through different techniques for the same page;
    • do not use robots.txt for canonicalization;
    • set it in the HTML: "The best way to set the canonical URL is to use HTML". If JavaScript must set it, it must never change a canonical already in the HTML, and there must be only one canonical link tag.

    Google "can only discover your links if they are <a> HTML elements with an href attribute". A click handler on a <div> is invisible. For client-side routing use the History API and real URLs, not fragments (#/products), because "don't use fragments to load different page content".

    A sitemap lists the URLs you want in results: "the canonical URLs". Google's sitemap documentation (last updated 2026-07-08) says it is a hint, not a guarantee; one file is limited to 50 MB uncompressed or 50,000 URLs; URLs must be absolute; <priority> and <changefreq> are ignored; and <lastmod> is used only when consistently and verifiably accurate. robots.txt can point to it:

    Sitemap: https://shop.example/sitemap.xml

    Generate the sitemap from the same data that generates the pages.

    robots.txt is not noindex

    Google's robots.txt documentation (last updated 2025-12-10) says the file "is used primarily to manage crawler traffic" and "is not a mechanism for keeping a web page out of Google": a blocked URL can still be indexed without its content if other pages link to it. Keeping a page out of results needs noindex (in a robots meta tag or an X-Robots-Tag header), and Google can only read it if crawling is allowed (robots meta tag documentation, last updated 2026-03-24). Blocking a page in robots.txt therefore hides its noindex from Google.

    Figure 2. Which control for which problem

    flowchart TD accTitle: Choosing the right control accDescr: A decision tree. A page that moved permanently gets a 301 redirect. A page that no longer exists gets a 404 status. A duplicate of another page gets a canonical link or redirect. A page that exists but should stay out of results gets noindex and must stay crawlable. Heavy crawl load is managed with robots.txt. Q{"What is the problem?"} -->|"moved for good"| A["301 redirect
    to the new URL"] Q -->|"gone"| B["404 status
    from the server"] Q -->|"duplicate of another URL"| C["rel=canonical or
    redirect to the original"] Q -->|"exists, keep out of results"| D["noindex, and do not
    block it in robots.txt"] Q -->|"crawlers load the site too hard"| E["robots.txt to manage
    crawling"]

    How to read this: each problem has one control. The common mistakes are robots.txt used to hide a page and a 200 used to say "gone".

    Structured data

    Structured data is a standard format for describing a page's content. Google supports JSON-LD, Microdata and RDFa and recommends JSON-LD "if your site's setup allows it" (structured data introduction, last updated 2025-12-10). Two rules matter for architecture. The markup must describe content visible on the page: "don't add structured data about information that is not visible to the user". And markup can make a page eligible for enhanced results, which is not the same as getting them. The JavaScript guidance says JSON-LD may be injected with JavaScript, with testing; rendering it in the HTML is simpler. The properties Google needs for a given rich result are on that feature's own page, which this lesson did not open, so this sketch shows the format only:

    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "Product",
      "name": "Trail Runner shoe",
      "description": "A light trail shoe with a grippy sole."
    }
    </script>

    This site as a worked example

    This course's own site (buildingai.in) applies these rules in its build, as its project documentation describes. It is one example, not a template.

    Concern What the site does
    Content in the first HTML Pages are prerendered to static HTML at build time and hydrated afterwards
    Unknown URLs A prerendered /404 page is served with a real 404 status; there is deliberately no catch-all rewrite, because it would answer every typo with the home page and a 200
    Private pages Author routes are excluded from prerendering and served client-only through explicit rewrites
    Sitemap and feed Generated at build time from the content files' frontmatter
    Checks A script lints the built site (metadata, canonicals, one H1, structured data, sitemap, robots, the 404 page, broken links) on every pull request

    Figure 3. How the site answers a URL

    A request for a known page gets prerendered HTML; an unknown one gets the 404 page with a 404 statusCrawler or browser to Static host (GET /url); Static host to Prerendered (known); Static host to 404 page (unknown)Crawler or browserCrawler orbrowserStatic host: explicit rewrites onlyStatic hostexplicit rewritesonlyPrerendered: known, status 200Prerenderedknown, status 200404 page: unknown URLs, status 404404 pageunknown URLs,status 404GET /urlknownunknown

    How to read this: the host decides the status before any JavaScript runs. A catch-all rewrite would remove the lower path.

    Trade-offs

    Decision Gain Cost
    Prerender everything Content in the first HTML, simple status codes Build time grows with pages
    Server-render per request Always fresh, any URL Server cost; streaming limits the status
    Client rendering behind a login Simple Not for pages that should be found
    Dynamic rendering for crawlers Quick fix for an old app A workaround; two versions to keep equal

    In practice: a shop moves its catalogue off client-side rendering

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A shop's category and product pages are a client-rendered app: the first HTML is a shell and a script tag. The team wants the catalogue findable. Unknown product URLs show "Product not found" with a 200.

    Starting architecture

    Figure 4. Starting architecture: the server answers every URL with the shell

    Starting architecture: a catch-all returns the same app shell with 200 for every URLCrawler to Host (any URL); Host to App shell (200); App shell to Catalogue API (after rendering)CrawlerCrawlerHost: catch-all to the shellHostcatch-all to theshellApp shell: empty HTML, status 200App shellempty HTML,status 200Catalogue API: called by the browserCatalogue APIcalled by thebrowserany URL200after rendering

    How to read this: nothing in the first response says what the page is, and a missing product looks like a real one until JavaScript runs.

    Options considered

    Option What it fixes What it costs
    noindex by JavaScript on missing products A soft-404 signal Relies on rendering
    Dynamic rendering for crawlers Crawlers get HTML A second pipeline; a workaround
    Prerender categories and products Content, status, links in the HTML Build time; price freshness
    Server-render per request Always fresh Server load; status before streaming

    The decision

    Field Decision record
    Context Content exists only after rendering; unknown URLs return 200.
    Decision Prerender category and product pages (incremental regeneration for price changes), generate the sitemap from the same data, serve a real 404 page for unknown URLs, keep the account area client-only and noindex.
    Consequences, good Content, links, canonicals and status are in the first response.
    Consequences, bad A build pipeline to run; removed products must become 404 or 410 promptly.
    Revisit when The catalogue outgrows the build.

    Rollout and verification

    1. Fetch like a crawler. curl -s ten sample URLs and check that title, canonical, h1, links and text are present without JavaScript.
    2. Check statuses. A missing product and a typo URL must return 404 from the server; an old URL that moved must return 301.
    3. Lint the built output in CI: one canonical per page, every sitemap URL answers 200, no internal link to a 404.
    4. Submit the sitemap and watch Search Console's indexing and soft-404 reports for a few weeks.

    The 2 a.m. failure

    A routing change adds a catch-all rewrite "so deep links always work". Pages load and tests pass. Two weeks later Search Console lists soft 404 errors for URLs nobody linked to. The catch-all returns the home page with a 200, so each unknown URL looks like a duplicate home page with a success status.

    First three checks: (1) curl -I a nonsense URL and read the status line; (2) read the host's rewrite rules for a pattern that matches everything; (3) compare the soft-404 URLs with the rewrite's scope. Mitigation: remove the catch-all and list the client-only routes explicitly. Follow-up: a deploy check that requests a random path and fails unless it gets a 404.

    Architect's notes

    • Decide per page type what must be in the first HTML; the rest can be client-rendered.
    • Never use 200 for "not found".
    • One URL per page. Fix duplicates with redirects first, canonicals second.
    • Run the lint in CI, not once at launch: regressions in the output are quiet.
    • Fingerprint your assets. Google's renderer may ignore caching headers, so a file name that changes with its content avoids stale scripts.

    Common mistakes

    • Hiding pages with robots.txt. A blocked URL can still be indexed, and its noindex is never read.
    • Using JavaScript to remove noindex. Google may not render a page that has it.
    • Navigating with click handlers and fragments instead of <a href>.
    • A catch-all rewrite that returns 200.
    • Structured data that describes content the page does not show.

    Key takeaways

    • Google crawls, renders (when the status is 200) and indexes; content in the first HTML does not wait for rendering.
    • Prefer server-side, static or incremental rendering for pages that must be found; Google calls dynamic rendering a workaround.
    • Return the true status: a 200 page saying "not found" is a soft 404.
    • Redirects and rel="canonical" are strong signals, sitemaps a weak one; set canonicals in HTML.
    • Links must be <a href>; routes must be real URLs.
    • robots.txt manages crawling; noindex manages indexing and needs crawling to be read.
    • A sitemap is a hint and should list canonical URLs only.
    • Structured data must match visible content; it makes a page eligible for enhanced results, not entitled to them.
    • Verify the built output with a script on every change.

    Practice exercises

    Exercise 1 - Starter (30 min): curl -s three pages of a site you know. List the text, links, title and canonical in the HTML before JavaScript runs.

    Exercise 2 - Intermediate (1 hour): Request a nonsense path on that site with curl -I. Write what you got, what it should have been, and which layer decides.

    Exercise 3 - Advanced (half day): Write a small script that reads a built site's sitemap and checks each URL for status 200, one canonical equal to the URL, and one h1. Add a request for a random path that must return 404.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments