Skip to main content

On-Page SEO Requirements

  • Last updated
  • View as Markdown

This document defines what every Market Data website must emit. Use it as acceptance criteria when building a new property, rebuilding an old one, or reviewing work from a contractor.

It binds every public property we run, whatever the stack: the Astro marketing site (MarketDataApp/website), this Docusaurus documentation site (MarketDataApp/documentation), any new site, free tool or content property, and the logged-in and tool surfaces — those mostly answer noindex, and §5 is where that is written down rather than assumed.

How to read this document​

This document is prescriptive. The per-repository specs are descriptive.

DocumentSays
this pagewhat a site MUST do, whatever it is built with
website/docs/SEO.mdwhat the Astro site emits today, measured in dist/
documentation/docs/SEO.mdwhat the docs site emits today, measured in build/
website/docs/SEO-DECISIONS.mdwhy we chose against a common practice
website/docs/SEO-GAPS.md, documentation/docs/SEO-GAPS.mdwhat is deliberately not gated, and why

A requirement here without a check named beside it is a requirement that will be broken and nobody will know. §18 is the map.

Must is a release blocker. Should is required unless the site records a written reason against it. Optional is a judgement call.


Origin-level and page-level requirements​

Some of what follows is owed once per origin, not once per site. Getting this wrong in either direction costs something real, and the two directions fail differently: a missing page-level tag is absent from one page, while a duplicated origin-level declaration is a second competing statement about the whole property.

www.marketdata.app is one origin serving two properties. The marketing site (MarketDataApp/website) is the root; the documentation (MarketDataApp/documentation) is a subfolder at /docs/. A subfolder property must not restate what the root already declares.

RequirementScopeOwner on this origin
robots.txtorigin — crawlers read only the root filethe root. A /docs/robots.txt is ignored entirely, so emitting one is worse than useless: it looks like a control and is not.
Web app manifestorigin — one installable app per originthe root (/site.webmanifest). A second manifest declares a second app with its own scope and start_url.
Organization node with sameAsorigin — it describes the company, not the pagethe root's home page. Emitting it on every page of a subfolder publishes hundreds of copies of one entity.
Site-verification tokenproperty — per Search Console propertythe root, when the property is a domain or an origin-prefix one, which covers everything beneath it. A subfolder needs its own token only if somebody registered a separate property for it.
Sitemapeither — an origin may publish severaleach property publishes its own, and the root's sitemap index must name them. An unreferenced sitemap is a correct file nothing advertises.
Everything else belowpagewhoever built the page.

Open Graph is page-level in full, including og:type and og:site_name. They look like site-wide constants and are not: both are properties of the document, and every page carrying Open Graph carries them.

When in doubt, ask what a second copy would mean. A second <title> is a defect. A second robots.txt is ignored. A second manifest is a second app. A second Organization is a second claim about who you are. If the answer is "nothing good", the requirement is origin-level and it belongs to the root.

Core Principles​

Measure the built output, never the source​

Every rule below is asserted against the built artefact — dist/, build/, or the deployed URL — and never against a component or template.

A rule that is true of a component and false of one built page is not a rule. This is not hypothetical. Three pages on the marketing site shipped an og:image:width of 1200 for a file 1024 pixels wide, while the component that emitted it read correctly. Thirty-five pages advertised an og:image that 404ed, because a path string was used where an import was needed — an import fails the build, a string does not.

Every requirement needs a check that names the offender​

A number written into prose is the one thing nothing can keep true.

Checks must print what they measured on every run, name every offending route, and never freeze a count as a baseline. The marketing site's spec carried "101 / 97 / 4" in thirty-three places for weeks while the build stood at 127 / 115 / 12, and nothing failed, because a citation gate has nothing to say about a number standing beside one.

State a floor far below any plausible content state, so it is a tripwire on the walk and never a second inventory somebody has to keep in step.

No silent caps​

If a check bounds its own coverage — top-N, no-retry, sampling, an API pagination limit — it must say so on every run. Silent truncation reads as "covered everything" when it did not.

lint:algolia is the worked example: one Algolia facet cell exceeds the 1,000-hit pagination cap, so the check reports the cap and confirms every apparent miss with a second targeted query before calling it a miss.

A page that produces no result has nothing to show​

Several rules below apply to indexable pages only. That is consistent, not an exemption. A description length budget is a rule about what a search result can display, so it does not bind a page that produces no result.


Quick Reference​

RequirementPriority
One <title>, unique, naming the companyMust
<meta name="description">, unique, 70–160 charactersMust
Self-referential absolute canonical on every indexable pageMust
No canonical at all on a noindex pageMust
Environment-derived robots directive, failing closedMust
Exactly one <h1>, no skipped heading levelsMust
alt on every <img>, never a filename or placeholderMust
Open Graph and Twitter card sets, echoes agreeingMust
og:image that resolves, at the size the head claimsMust
JSON-LD that parses, is typed, and is connectedMust
Sitemap listing every indexable route, and nothing elseMust
robots.txt naming a sitemap the build emits (origin)Must
Analytics on production onlyMust
Every built route reachable by the search indexMust
Markdown twin per routeShould
llms.txt, withholding noindex routesShould
Breadcrumb trail matching its BreadcrumbListShould
Web app manifest with an id and a maskable icon (origin)Should
Core Web Vitals inside Google's "good" thresholdsShould
hreflangOptional — see §1

1. Document Basics​

Every page must carry, once each:

TagValue
<html lang>en
<meta charset>UTF-8
<meta name="viewport">width=device-width,initial-scale=1
<meta name="google-site-verification">the property's own token

The verification token is origin-level. A subfolder inherits the root's when the Search Console property is a domain or origin-prefix one, and needs its own only if somebody registered a separate property for the subfolder. Check which before adding a second token.

The other three are singletons. A check must count them rather than merely require one: react-helmet-async and several other head managers render a second tag rather than letting a later one win, so "at least one" passes on a page serving two contradictory values.

hreflang is optional and currently unused. We publish one locale. A self-referencing hreflang on a single-language site adds a tag and changes nothing.


2. <title>​

  • Must be present, non-empty, and more than the site name alone.
  • Must contain Market Data. Use contains, not ends-with — the home page leads with the brand, and the legal pages read Market Data Terms of Service.
  • Must be unique across indexable pages.
  • Must be 60 characters or fewer. Google truncates a result past roughly that width.

A page's <title> and its <h1> may differ, deliberately. A headline is written for the reader who arrives; a title is written for a 60-character result. Where a page sets an explicit SEO title, <h1> keeps the published headline and <title> carries the short form.

The brand appears once on a card​

The rule is not "og:title must never carry the suffix". It is that the company name appears once on a shared link. Two configurations satisfy it, and both are correct:

og:titleog:site_nameCard reads
`Stock Candles \Market Data`absentbrand once, in the title
Stock CandlesMarket Databrand once, beside the title

<title> always keeps the suffix, in both configurations. Google has no equivalent of og:site_name, so <title> is the only place the brand can appear in a search result.

Do not add og:site_name on its own. Every social client renders it beside the title as its own element, so adding it to a page whose og:title already carries the suffix moves that page from correct to saying the company name twice. If you add it, strip the suffix from og:title in the same change or not at all.

This paragraph was wrong until 2026-09-04 and the history is worth keeping. It read "<title> ends in | Market Data. og:title must not", copied from a source comment in MarketDataApp/website that states a conditional rule as a site-wide prohibition. That repo's own check says otherwise in terms the comment does not: the prohibition holds on the 47 pages with a headline of their own, while 54 echo <title> and 40 of those legitimately carry the suffix — pending a copy decision, deliberately not gated, because a gate that fails correct-as-authored pages is one somebody switches off.

So a Must in this document was manufactured by reading a comment rather than the check beside it. A rule copied from another repository must be verified against that repository's gate, not its prose.


3. <meta name="description">​

  • Must be present and non-empty on every page.
  • Must be unique across indexable pages.
  • Must be between 70 and 160 characters on indexable pages.
  • Must be echoed verbatim into og:description, twitter:description, and the description field of the page's JSON-LD node. All four agree.

Generated descriptions are acceptable for archive-shaped pages, and should name the parent topic. Options Education alone says nothing in a result.


4. Canonical URLs​

  • Every indexable page must emit exactly one <link rel="canonical">.
  • It must be absolute, and must name the production host — never the staging host, never the Pages host, never a relative path.
  • It must be self-referential, except a declared archive variant, which names its archive's base rather than itself.

A noindex page emits no canonical​

Must. A canonical on a noindex page is a contradiction: it asks a crawler to consolidate signals onto a URL that also asks not to be indexed. The 404 page is the clearest case — it has no URL of its own, so it names none.

Note the interaction with §5: a staging build marks every page noindex, so a staging build emits no canonical on any page. A check must assert the absence, not only the presence.

The canonical and the structured data must agree​

Where a page emits a JSON-LD node with a url or @id, that value must equal the canonical exactly, trailing slash included. A site with trailingSlash: true whose framework hands out permalinks without one will produce two spellings of the same page and nothing will report it.


5. <meta name="robots"> and Environment​

Every page must carry exactly one robots directive, and it must be derived from the build environment rather than hard-coded.

EnvironmentEvery page gets
productionindex, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1 for content; noindex, nofollow for the noindex set
stagingnoindex, nofollow on every page, and no canonical on any page
environment unset, in CIa thrown error, not a default
environment unset, locallya printed warning, and the safe direction

max-image-preview:large is the directive that costs something if omitted: without it Google falls back to a thumbnail in results, so banner images are not used at full size.

A noindex directive should omit the three preview directives. They cap what a result may show, and a page producing no result has nothing to cap.

The environment must be derived from the branch by one expression that the deploy workflow and the check both read. Two strings that must agree will eventually disagree. Failing closed on an unset value is the requirement that matters: a default is how a staging build ships index, follow.

Tool and app surfaces​

Logged-in surfaces, dashboards, admin tools and internal reference material must answer noindex, nofollow, must be excluded from the sitemap, and must not appear in llms.txt or any search index.

An internal page must not rely on obscurity. /docs/internal/ is the pattern: absent from the navbar, reachable by URL, and noindex by a tag that six downstream consumers read.


6. Heading Structure​

  • Must have exactly one <h1> per page.
  • Must not skip a heading level.
  • The <h1> should equal og:title and twitter:title.

Check this against the built page. A heading that arrives through a component prop is invisible to a grep over sources.


7. Images and alt​

  • Every <img> must have an alt attribute.
  • alt must not be a filename, a placeholder, or a repeat of the page title.
  • A decorative image must carry alt="", deliberately empty.

An alt that repeats the title is worse than an absent one. It reads as a description to every automated check and to nothing else. The same judgement applies to og:image:alt: describe the picture, or omit the pair entirely. Empty and absent must behave identically.


8. Open Graph and Twitter Cards​

Required on every page:

og:type website, or article for a post
og:site_name Market Data -- ONLY when og:title drops the suffix. See
"The brand appears once on a card" in section 2.
og:title the page's own title, WITHOUT the brand suffix
og:description verbatim from meta description
og:url equal to this page's canonical
og:image absolute, resolving, and the size the head claims
og:image:width the file's real pixel width
og:image:height the file's real pixel height
og:image:alt what the picture shows, or omitted
twitter:card summary_large_image
twitter:title == og:title
twitter:description == og:description
twitter:image == og:image
twitter:image:alt == og:image:alt

An article must also emit article:published_time and article:modified_time as ISO dates.

og:image and twitter:image live in a <meta content> attribute, so no link checker has ever resolved them. A dedicated rule must open them. This is not theoretical: thirty-five pages once advertised a 404ing og:image while also declaring summary_large_image, and every one of those shared links rendered an empty card.

The declared width and height must be read from the file, not written by hand.


9. The Card Image​

  • Every page must resolve to a card image. A fallback to the brand mark is correct, not a defect.
  • The image must be an asset the build emitted, referenced by import and never by path string. An import fails the build; a path string ships.
  • The image must be flattened and opaque. Transparency renders as black on several clients.

Where a framework emits modern formats through a <Picture>-style component, the source file may never be emitted at all. Derive the card explicitly rather than pointing at the source asset's presumed URL.


10. Structured Data (JSON-LD)​

  • Must parse.
  • Must be typed — every node carries an @type.
  • Must be connected — no node floats unreferenced, and no @id points at a node that does not exist.
  • Must contain exactly one page node, whose @id is the canonical URL.
  • Should include an Organization node with sameAs — once per origin, on the root's home page. A subfolder property must not restate it: emitting it on every page publishes hundreds of copies of one entity, and the entity is the company rather than the page.

A page node's breadcrumb field is an @id reference. Do not emit it on a page that emits no BreadcrumbList — a pointer to nothing is worse than an absent field.

Choose the type that describes the page. /pricing/ emits WebAPI, not Product, because we sell API access rather than an object.


11. Internal Linking and Breadcrumbs​

  • A rendered breadcrumb trail and its BreadcrumbList must be the same trail. A check that reads only the JSON-LD cannot see a disagreement.
  • Anchor text should not be a bare URL. Exceptions, recorded rather than assumed: API documentation where the URL is the content, legal copy citing a third party, and a URL inside a code block.
  • Internal links must be relative or name the production host. An absolute self-link resolves and renders, and moves a reader from staging onto production without a symptom.

12. Sitemap and robots.txt​

  • The sitemap must list every indexable route and nothing else.
  • Every URL in it must answer 200 on the deployed site.
  • Every URL must use the production host and the site's own slash form.
  • A noindex route must never appear.
  • robots.txt must name a sitemap the build actually emits. It is origin-level: crawlers read only the root file, so a subfolder's robots.txt is ignored entirely and emitting one is worse than useless — it looks like a control and is not.
  • A subfolder's sitemap must be named in the root's sitemap index, or as a second Sitemap: line in the root robots.txt. An unreferenced sitemap is a correct file that nothing advertises, which is indistinguishable from having none.

A noIndex build must publish no sitemap at all. Assert the absence on staging, not merely the contents on production.

Cache-warm and cache-busted probes​

When a page count matters, cache-bust.

ProbeRequestAnswers
Cache-warmcurl <url>what a visitor is served right now
Cache-bustedcurl "<url>?cb=$RANDOM$RANDOM"what the deployment contains

Run both and compare. A disagreement is a delayed failure, already queued — the page looks correct today and starts 404ing when the cache expires, with no new deploy and no new cause. A dead URL served 200 from a warm edge cache never enters a 404 report at all, so those counts are a floor rather than a total.


13. Markdown Twins and llms.txt​

  • Every built route should have a Markdown twin, and a missing twin must fail the build rather than warn.
  • Twins should be advertised on the page, at the URL that actually serves them.
  • llms.txt must withhold every noindex route.
  • A truncated index must fail rather than pass. An empty file satisfies "contains no noindex route" while telling a reader nothing.

Twin generation must fail the build because the failure is invisible from outside. Where a rewrite rule maps <route> to <route>index.md unconditionally, a route with no twin is a 404 with no deploy and no other cause.


14. Analytics​

  • The analytics loader must be present on every production page, including the 404.
  • It must be absent from every staging page.
  • The environment decision must come from the same resolver §5 uses.

15. Web App Manifest​

Origin-level: one per origin, and it belongs to the root. A subfolder property must not ship a second — that declares a second installable app with its own scope and start_url, which is a defect rather than a gap.

Where a site ships a manifest, it should:

  • name the site rather than the scaffold's default,
  • declare an id, so a future start_url change cannot orphan an installed app,
  • ship every icon it promises,
  • include a maskable icon whose mark stays inside the 80% safe zone, and
  • offer at least one unmasked icon.

16. Core Web Vitals​

Each should sit inside Google's "good" threshold on mobile: LCP under 2.5s, INP under 200ms, CLS under 0.1.

Measure the property, not the metric. A CLS number read from a local server is 0.00000 on a page that scores 0.486 at 375px with images delayed, because the server answers before the shift can happen. Assert instead that an image occupies the same box before and after it loads — that is a property, and it holds regardless of how fast the network was on the morning of the run.


17. Search Indexing​

Site search is a first-class SEO surface and must be watched.

  • The index must be no older than the crawl schedule plus slack.
  • Every route in the sitemap must be reachable by a search against the index.
  • No noindex route may appear in it.
  • Index settings must be asserted, not assumed.
  • The crawler must have a schedule. A null schedule is a stalled crawler that reports itself healthy.

A search index fails silently by construction. A stale index and a fresh one are the same shape, answer in the same time and render identically, and a visitor cannot miss a page they do not know exists. The docs index sat six months stale — the entire C# SDK returning zero hits — while every other surface stayed green.

Where the crawler owns the index settings, a settings change must be made in the crawler's config, not on the index. Otherwise the next reindex reverts it, and under a DocSearch-style provisioning there is no admin key to put it back.


18. What Must Gate What​

A requirement with no check is a requirement that will be broken silently. Each site owns its own check names; what this section fixes is that every row has an owner.

RequirementMust be gatedRuns against
§1 head singletons, lang, charset, viewportstatic checkthe build
§2 title presence, brand, length, uniquenessstatic checkthe build
§3 description presence, length, uniqueness, echoesstatic checkthe build
§4 canonical present, absolute, self-referentialstatic checkthe build
§4 canonical absent on noindex pagesstatic checkthe build
§5 robots directive, and the environment resolver's own tablestatic checkthe build
§5 the branch-to-environment map matching the deploy workflowstatic checkthe workflow file
§6 one h1, heading orderstatic checkthe build
§7 alt present, and not a filename or placeholderstatic check + a11ythe build
§8 the OG and Twitter sets, and their echoesstatic checkthe build
§8 og:image resolving, and its declared sizestatic checkthe build
§9 one card image per page, resolvingstatic checkthe build
§10 JSON-LD parses, typed, connected, one page nodestatic checkthe build
§11 rendered trail equals BreadcrumbListstatic checkthe build
§12 sitemap coverage, hosts, slashesstatic checkthe build
§12 every sitemap URL answers 200post-deploy testthe deployed site
§13 every route has a twinbuild step, failing the buildthe build
§13 llms.txt withholds noindexstatic checkthe build
§14 analytics per environmentstatic checkthe build
§15 manifest, icons, safe zonestatic checkthe build
§16 image box stabilitybrowser checka served build
§17 index freshness, coverage, settingsscheduled checkthe live index

Placement rule. A check whose subject is the commit belongs in PR checks. A check whose subject lives on a third party's servers belongs on a schedule: no pull request can move it, and a check people cannot act on is one they learn to skip.

Coverage-floor rule. Every check that walks a discovered population must assert a floor on what it found. A sweep over an empty directory otherwise reports a clean pass, and that is the failure mode most likely to survive review.


Acceptance Checklist​

Before accepting a new site, or a rebuild of an existing one:

  • One <title>, unique, contains Market Data, 60 characters or fewer
  • One <meta name="description">, unique, 70–160 characters
  • <html lang>, charset, viewport, verification token — one each
  • <title> carries the brand suffix and og:title does not

Indexability​

  • Every page carries exactly one robots directive
  • Staging is noindex, nofollow on every page
  • An unset environment throws in CI rather than defaulting
  • Every indexable page has a self-referential absolute canonical
  • No noindex page emits a canonical
  • Tool, admin and internal surfaces are noindex and out of the sitemap

Content signals​

  • Exactly one <h1>, no skipped levels
  • Every <img> has alt; none is a filename or placeholder
  • Every page renders more than 500 characters of body text

Sharing​

  • The Open Graph and Twitter sets are complete
  • og:url equals the canonical
  • og:image resolves, is opaque, and matches its declared dimensions
  • og:image:alt describes the picture, or is absent

Structured data​

  • JSON-LD parses, is typed, and is connected
  • Exactly one page node, @id equal to the canonical
  • No @id reference points at a node that does not exist
  • The rendered breadcrumb trail equals its BreadcrumbList

Discovery​

  • The sitemap lists every indexable route and nothing else
  • Every sitemap URL answers 200 on the deployed site, cache-busted
  • A noIndex build publishes no sitemap
  • robots.txt names a sitemap the build emits (origin-level; a subfolder emits none)
  • A subfolder's sitemap is named in the root's index, or in the root robots.txt
  • Every built route has a Markdown twin
  • llms.txt withholds every noindex route

Operations​

  • Analytics loads on production and on no staging page
  • The search index is fresh, covers the sitemap, and excludes noindex
  • The search crawler has a schedule
  • Every row in §18 has a named check that fails when the rule is broken
  • Every walking check asserts a floor on the population it found
  • No check bounds its coverage without saying so on every run