Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Field note

Technical SEO for a Blazor site: what the crawler actually sees

A crawler sees bytes, not intent. Static server-side rendering, one content source for pages, sitemap and feeds, a real 404 and immutable fingerprinted assets get a Blazor site most of the way. The rest is reading your own output: an early export of this site showed a truncated title, modified dates hours before the published dates, an author link to nowhere and a configuration flag the renderer ignored. Each one with the rule it breaks and the fix.

Filed
2026-10-16
Topic
Engineering systems
Reading
9 min
Tags
#seo#blazor#osirion#cloudflare

Part 3 of 3 Engineering systems

  1. Pre-MVP: make it right, never comply
  2. The quality gate that could not see async code
  3. Technical SEO for a Blazor site: what the crawler actually sees
Previous The quality gate that could not see async code

The claim

A crawler sees bytes, not intent. For a Blazor site the structural half of technical SEO is settled by three decisions: render on the server to complete HTML, generate pages, sitemap and feeds from one content source, and serve a real 404 and immutable fingerprinted assets. The other half is reading your own output, because the library's defaults and the build environment both lie in small ways, and a modified date hours earlier than the published one is not what automated audits look for. This note is what an early static export of this site showed on 2026-10-16, and what each finding changed.

Why the HTML is complete before any script runs

Google processes JavaScript sites in three phases, crawling, rendering and indexing, and the rendering happens later, from a queue: "The page may stay on this queue for a few seconds, but it can take longer than that." The same page says what to do about it: "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."

Blazor's static server-side rendering produces complete HTML per request, with no circuit and no WebAssembly. This site goes one step further: a tool crawls the running application, follows every same-site link in HTML, CSS and XML, saves each response under its address, and fails the deployment if any link answers anything but 200. Cloudflare Workers static assets serve the result. /blog/a-schema-per-tenant is a file named blog/a-schema-per-tenant.html, served without a trailing slash so the address on disk and the canonical address agree, and an unknown address answers a real 404 with the site's own not-found page rather than a 200 with "not found" written on it. The site's own stylesheet, the scoped CSS bundles and the framework script carry a fingerprint in their file name and Cache-Control: public, max-age=31536000, immutable, so a change is a new address and the old one can be cached for a year; the stylesheets and scripts the library links by a plain path carry neither.

One source for pages, sitemap and feeds

Osirion.Blazor, the open source library this site is built on, renders the pages, /sitemap.xml, /feed.xml and /atom.xml from the same content items. A post that exists is in all four; a post that does not exist is in none. Each page carries a canonical link, Open Graph and Twitter tags, and two JSON-LD blocks, a BlogPosting and a BreadcrumbList. Google's structured-data documentation lists Article, NewsArticle and BlogPosting as the supported types and says there are no required properties, only recommended ones; image is among them. The early export showed "image": null; each post now names its cover there, the same image it shows when it is shared.

What reading the export found

Each finding comes from one HTML file in the export and one line of the library's source, and each fix went into the library; what matters for this note is the rule each one breaks.

1. The title was truncated into noise. The page's <title> read "A schema per tenant: the database owns the name | Dejan...". The library builds title | site name and cuts the whole string at 60 characters, so a long title eats the site name and leaves an ellipsis. Google's guidance is the opposite of a character limit: "there's no limit on how long a <title> element can be", the title link "is truncated in Google Search results as needed, typically to fit the device width", and the brand should be concise, with the site name "at the beginning or end" and never as boilerplate. The rule: never truncate the <title> element itself. Write the title and add the site name whole or not at all. The library now writes the <title> whole. It still cuts og:title at 60 characters and twitter:title at 70; those are its own conventions, since the Open Graph protocol sets no length for og:title.

2. Every page carried the checkout time as its modified date. dateModified, article:modified_time and the sitemap's <lastmod> all read 2026-10-16T07:17:23Z, for every page. Ten of the eleven posts had a datePublished between 09:00 and 10:40 the same day, so they were "modified" up to three and a half hours before they were published; the eleventh, published on 2026-10-11, was "modified" five days later. The library took a post's modified date from its front matter when it had one and otherwise from the file's modification time; on a CI runner, the file's modification time is the moment of the checkout. Google says the lastmod value "should reflect the date and time of the last significant update to the page" and that it is used "if it's consistently and verifiably" accurate, verified "by comparing to the last modification of the page". A build time is neither. The rules: a modified date comes from the content (a last_modified field, set when a post changes) or is not emitted; a modified date earlier than the published date is a bug, not data; and a build must never invent a date. The library now takes the front matter's last_modified or, without one, the published date, and reads a file's time only when a site turns that on for files edited in place.

3. The author link pointed at nothing. The BlogPosting named the author's URL as /author/dejan-demonjić, built from the author's name, lower-cased, with a non-ASCII character, for a page that does not exist. Google asks that author.url be "a link to a web page that uniquely identifies the author", such as a bio page. The rule: an author URL is configured and points at a page that answers 200, or it is omitted. The library now names one only when the site configures it; this site names its About page.

4. The publisher logo had invented dimensions. The publisher.logo declared 600 by 60 for a square SVG. The numbers were a constant in the library. Dimensions are either read from the image or left out, and the library now leaves them out.

5. The configuration said one thing and the page said another. This site's settings set the library's AllowAiTraining option to false. The rendered head said <meta name="ai-training" content="allow"> and <meta name="Google-Extended" content="allow">. The renderer applies the options for the site name, the logo and the default image, and never reads the AI flags; the component's own default, true, wins. Whatever one thinks of those meta tags, a renderer that ignores a setting is a defect, and the only way to catch it is to compare the rendered head with the options in a test. Those tests now exist in the library and cover every switch, and they are the kind of test that should have come first. With the switches read, the setting decides what the page says: the site now allows training, and the page says so because the setting does.

6. The robots file disallowed a route that does not exist and named no sitemap. The default robots.txt is User-agent: * and Disallow: /search/, with no Sitemap: line. The site has no search route. The library lets a site provide a robots content item instead, but writes out the item's rendered HTML rather than its text, so this site serves a plain robots.txt file that allows every crawler and names the sitemap.

None of these would have broken the site for a reader. All of them change what a crawler records about it, and two of them (the dates and the ignored flag) would have been wrong for as long as nobody read the output.

What tools find, and what they do not

Automated audits earn their place: Lighthouse runs, a link inspector and a robots checker catch missing tags, broken links, blocked resources and slow pages. None of them would have flagged a modified date hours before the published date, a syntactically valid author URL that returns 404, or a meta tag that contradicts a setting. Those are semantic, and semantics you find by reading: open the exported HTML, read the head top to bottom, open the sitemap, and ask of every value where it came from.

Two practices from polovnjak.net, a multilingual calculator, carry over. Every page there declares hreflang for its language set with an x-default, the same set is repeated in the sitemap, and the <html lang> is the full tag (sr-Cyrl-RS, bs-BA) because Bing, screen readers and the browser read it even if Google infers the language from the text. After a deploy that changes content, a script submits every address in the live sitemap to IndexNow, which passes them to the search engines that take part so they can recrawl those pages instead of waiting to find the change; the key is a public file on the site by design. Neither is needed on a one-language blog yet; both are a few lines when it is.

What it cost

  • A static export step in the deployment that fails on a broken link, which exists to stop a deployment that carries one.
  • A last_modified field to set by hand whenever a post changes materially.
  • Five fixes to the library, each with a test that compares rendered output with configuration.
  • An afternoon of reading HTML, which is the cheapest afternoon in this whole series.

What a team can take from this

Render on the server and export if you can; the crawler should never need your JavaScript. Build the pages, the sitemap and the feeds from one source so they cannot disagree. Serve a real 404. Then read your own output, byte by byte, before you trust a score: the title, every date, every URL in the structured data, and every flag your configuration claims to set. If a value cannot say where it came from, it should not be on the page.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Next Pre-MVP: make it right, never complyBefore the first release there is nothing to stay compatible with. Code that adapts to older code, an older schema or stale development data is a defect, not a kindness. The rule, how it was found, where it has held and where it is shaping decisions not yet built, and the day it should end.

In this entry

  1. The claim
  2. Why the HTML is complete before any script runs
  3. One source for pages, sitemap and feeds
  4. What reading the export found
  5. What tools find, and what they do not
  6. What it cost
  7. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.