Skip to content

Technical SEO for static sites in two languages

Technical SEO for static sites in two languages: crawling, image alt text, hreflang, sitemap, noindex, structured data and Search Console verification.

intermediatePublished on · 34 min read

Font size:
Column width:
Tone:
Line spacing:
Typeface:

Technical SEO for static sites in two languages

A well-written page can vanish from search for reasons that have nothing to do with the text. An image that reads “3 01 chamada”, an that points to the home page of another language, a that stops Google from reading the rule that would have removed the page from the index. These mistakes are invisible to anyone reading the blog and only show up in an audit, and most of them are born when the content is created, not afterwards.

This lesson follows one real case. The blog is an Astro site with posts in Portuguese, an English version under /en/, hosting on S3 with CloudFront and its own domain. Google flagged two problems (diagrams with weak alt text and badly structured hreflang), the investigation found others, and the fixes followed a plan in phases. By the end you should understand how Google discovers, crawls, indexes and serves a page, why every image needs alt text written on purpose, how to declare two languages without confusing Google, when to use and when never to use robots.txt for that, and how to check everything before and after a deploy.

The lesson assumes you know what HTML and a URL are. Everything else is introduced here. Whatever changes over time (Google’s rules, tools) was checked against the official documentation on October 30, 2026, and what I could not confirm is stated in the sources section. The diagrams are animated and were generated with ArchFlow.

Lesson map

PartSectionsQuestion it answers
1. How Google finds the page1What are the steps between a URL existing and showing up in search?
2. What lives inside the page2 and 3Which texts does Google read, and how is the alt of diagrams built?
3. More than one language4How do PT and EN declare each other without confusing Google?
4. What goes in and out of the index5 and 6When to use sitemap, robots.txt, noindex and ?
5. Data and domain7 and 8Which structured data and which domain does the page declare?
6. Validating and avoiding pitfalls9 and 10Which tool sees what, and where does verification fail?

The diagrams follow a fixed color map. Orange is what you control (content, configuration, site files). Blue is what the build or Google processes. Green is what reaches the index or the good result. Pink is what goes wrong. Gray is neutral.

Part 1: how Google finds the page

1. Discovering, , and serving

Picture a gigantic library with a librarian who never sleeps. Before any book is on a shelf, the librarian has to know the book exists, walk to the shelf, read the book and decide whether it goes into the catalog. Only then can it be recommended to someone who asks. Google works in four similar stages, and mixing one up with another is the origin of almost every SEO problem.

The first stage is discovering the URL. Google’s documentation lists two paths: a link from a page it already knows, such as a list of posts pointing to a new post, or a sitemap you submit with the list of pages. The second is crawling: Googlebot, the collection program, downloads the page’s HTML and resources. The third is indexing, when Google analyzes the content and the main tags and stores everything in an enormous database, which it calls the index. The fourth is serving the result: when someone searches, the system picks pages from the index and orders them by relevance. That ordering is the .

The difference between “crawled” and “indexed” is the one that confuses most people. Google can download a page and decide not to keep it, because it has noindex, because it is a copy of another page or because it is too thin. The documentation itself says indexing is not guaranteed and that not every processed page enters the index. Search Console shows the two situations as different states, which is why “Google visited” alone does not mean the page will appear.

A URL's path: Googlebot crawls it, robots.txt can block it, indexing reads the HTML and noindex decides whether it enters the search index
figure 1 · A URL's path from crawling to delivery in Google

The figure shows the whole path. At the top, two discovery paths, the and a link from another page, lead to crawling (blue, pulsing, because it is the step that spends Google’s access). Then comes the robots.txt blocking decision. If it blocks, Google never reads the page or the noindex, and the pink error path shows that. If it does not block, indexing reads what is in the HTML and reaches a diamond: is there a noindex? If so, the page stays out of the index; if not, it moves on to serving, in green. Notice that the order matters: robots.txt is consulted before crawling, and the noindex is only read afterwards. Section 5 explores this order.

Part 2: what lives inside the page

2. Title, description, H1 and heading hierarchy

The page title is what appears as the name of the result in search and in the browser tab. The title documentation asks every page to have its own <title>, descriptive and concise, in the language of the content, and not repeating boilerplate text from a template. This matters because the “one title per page” rule affects search: if all the blog’s tag pages carry the same title, Google has several identical candidates and picks any of them.

The meta description is the supporting text of the result. It can be used in the snippet shown below the title, but Google’s documentation says it mainly uses the page content to build the snippet, so the description can be replaced. It also has no declared length limit, although the snippet is cut according to screen width. The blog’s validator uses 120 to 160 characters as the ideal range. That is a project convention, not a Google rule, and the difference matters when someone asks where the number comes from.

The H1 is the main heading of the content, and each page should have only one. On the blog, the post header already generates an <h1> with the title. But many posts came from Obsidian notes, where the note title is the H1 and the parts start with # Part 1. The result was two or more main headings on one page. The OpenSEO audit flagged 88 pages with this problem.

The fix was a plugin that runs at build time and, when the body text has any <h1>, demotes every heading one level. The header remains the only H1, and the parts become H2. The anchor identifiers do not change, so the table of contents and internal links keep working.

Heading hierarchy before and after the rehype-demote-h1 plugin: the header H1 stays unique and the Markdown h1s drop to h2 and h3
figure 2 · Heading hierarchy of a post before and after the H1 demotion

In the figure, the left column shows the problem: two headings marked as H1 (pink) competing for the page’s subject. The right column shows the result after the plugin: the header stays green and unique, and the body headings, in blue, drop one level. The hierarchy no longer skips steps, which also helps screen readers navigate the outline.

The same audit found repeated titles and descriptions. Tag listings shared the site’s default meta description (170 pages), and 46 pages had a duplicate title, mostly pages 2, 3 and onwards of each tag. The fix was to generate a title and description for each listing, with the post count and the page number, and to put the thin pages outside the index (section 6).

3. Alt text for images and diagrams

This is the section that weighs most in the case, because it is the one Google flagged. Alternative text, the alt of an image, has two jobs. When the image does not load, it appears in its place. For people who use a screen reader, it is the only way to hear what the figure shows. And Google uses it to understand the image, together with the caption and the surrounding text. The image documentation asks for an alt that describes the image in the context of the page, with no stacked keywords, and says the file name gives “very light hints” about the subject.

The blog’s problem began with how diagrams entered the posts. The author wrote ![3 01 chamada](./assets/1-3-01-chamada.svg), and the text between the brackets became the alt. It repeated the file name, and the result was something like alt="3 01 chamada". Of the blog’s 687 alts, 116 followed this pattern, and the shortest had 3 characters. For someone who cannot see the figure, that describes nothing.

While investigating, the project itself revealed where the description lived. Every SVG generated by ArchFlow has a <title> and a <desc> inside the file. The title is the sentence that names the figure, and the description lists the elements and connections. A real SVG from the blog carries, at the top of the file, something like <title id="svg-creator-title">Amazon S3: Ciclo de Vida dos Objetos (Lifecycle Rules)</title> followed by a <desc> that enumerates the arrows. All 682 SVGs on the blog have both elements.

Here is the central point, and it is necessary to separate fact from reasoning. When an image is loaded through <img src="figure.svg">, the browser treats the file as an image, and its internal content is not part of the page DOM. A screen reader, for example, only announces the alt of the <img>. Google’s image documentation mentions <title> only for inline SVG, and does not say whether it reads the text inside an SVG used in an <img>. The project concluded that the <title> and <desc> do not reach Google in this format, so they do not count as indexable text. That conclusion comes from how <img> works and from the blog’s own diagnosis, not from a rule published by Google. As a precaution, the alt has to be written so that it works on its own.

The blog does this at build time, without requiring the author to rewrite every alt. The rehype-diagrams plugin reads the first 16 KB of each referenced SVG, extracts the title and the description, and decides the alt. If the alt written in Markdown is empty or equal to the file name (the comparison ignores case, repeated spaces and the hyphens of the name, and strips the leading numbering), the plugin builds a new alt with the buildAlt function. It joins the title with the useful part of the description, dropping the “Diagram with N elements” prefix and the closing count sentence, and cuts the text at a limit of 160 characters. The cut never lands in the middle of a connection: if the next connection does not fit, the alt ends there with an ellipsis.

I ran this function on the S3 SVG to check the result. The title is “Amazon S3: Ciclo de Vida dos Objetos (Lifecycle Rules)” and the generated alt has 108 characters: the title, followed by “Novo Objeto / Upload → S3 Standard (Upload inicial)” and an ellipsis. The next connection, “S3 Standard → S3 Standard-IA”, did not fit the limit and was left out. The figure caption uses the title, so the reader sees “figure 4 · Amazon S3: Ciclo de Vida dos Objetos” and Google receives an alt that makes sense.

The 160 limit comes from the code, in the plugin’s MAX_ALT constant, and was fixed by decision D3 of the correction plan. I did not find in the repository a measurement showing that 160 is the ideal number. What the lesson can state is what the code does: cut at 160, always at a connection boundary. The floor of 80 characters that I use as a writing rule for diagram alts is not checked by the code. The validator rejects an alt with fewer than 15 characters and an alt equal to the file name with fewer than four words, and nothing else. A 40-character alt passes, even if it is poor. Whoever writes the alt by hand has to follow the 80 to 160 rule on their own.

The plugin reads the SVG title and description at build time and builds an alt of up to 160 characters, the only text of the figure that Google reads
figure 3 · How a diagram SVG's alt text reaches Google

The flow in the figure sums up the decision. The SVG and the Markdown come in from the left. The plugin, in blue, reads the SVG’s title and description and, if the Markdown alt is bad, replaces it with a built one. The build produces text of up to 160 characters, which goes to the <img> and to the caption. Only from that point on does Google see anything, and the green path shows that it is the alt that arrives. The drawing has no direct path from the SVG to Google, precisely because the project concluded that this path does not deliver the text. In practice, the descriptive text has to go through the alt.

One last rule comes from the documentation: alt is not for fitting in keywords. An alt that repeats terms to rank hurts the reader and can be treated as spam. That is why the blog’s validator rejects an empty alt and an alt that is just the file name, and does not require the alt to contain certain words.

Part 3: more than one language

4. hreflang, and self-reference

Imagine a road sign with two plates pointing to the same city, one in Portuguese and one in English. Each plate says “this is the version of this page in this language” and points to the other. hreflang is that plate for Google. It is a <link rel="alternate" hreflang="..."> that says which language and region version exists for a page, so Google shows the right one to each person.

The localized versions documentation imposes three rules that break the implementation when ignored. The first is reciprocal return: if the Portuguese page points to the English version, the English version has to point back. Without that, Google ignores the tags. The second is self-reference: each version lists itself along with the others. The third is using absolute URLs, with the protocol. Google accepts all three declaration methods, which are the page <head>, the HTTP header and the sitemap, and says they are equivalent.

The x-default value plays another role. It indicates the page to use when no language matches the visitor’s browser. The blog uses the Portuguese version as x-default, because Portuguese is the site’s main language.

On the blog, Base.astro emits these tags in the <head> of every page that has a translation. In the hexagonal architecture post, the HTML declares three links: pt-BR pointing to /posts/arquitetura-hexagonal/, en pointing to /en/posts/hexagonal-architecture/ and x-default pointing to the Portuguese version. Notice that the slugs are different in the two languages. That detail caused the first sitemap problem.

The sitemap has to say the same thing. @astrojs/sitemap only pairs URLs when the path is identical in both languages, and since translated posts have different slugs, the sitemap came out without language annotations for most of them. Only 34 of the 354 URLs had xhtml:link. The HTML declared the pair and the sitemap did not, which is exactly the divergence Google cites as a weak signal. The fix, in src/lib/sitemap-links.mjs, pairs posts by the translationKey in the frontmatter, the same key the HTML uses. After the build, the sitemap carries, for each translated post, both languages and the x-default, and each URL appears with self-reference, as the documentation requires. The validator’s automatic test compares the HTML link set with the sitemap’s and fails if they diverge.

A Portuguese and an English post declare each other in the head and in the sitemap, with self-reference; a post without a translation carries no hreflang
figure 4 · hreflang pairing between a Portuguese post and its English version

The figure shows the pairing. The two pages, in orange, declare each other in their own <head> (blue), with reciprocal return in the two-way arrow. The sitemap also links them (blue, at the bottom), and Google compares the two. The case on the right is the problem the blog had in the language switcher: a Portuguese post without a translation sent the EN button to the /en/ home, with hreflang="en". The home is not the version of that article, so the attribute was lying. Today the button is still visible, but without the attribute, with a notice that there is no English version, and the dotted line shows that link without a declaration.

The rule that comes out of this is simple. hreflang only asserts an equivalence when it exists in both directions. Before declaring a pair, confirm that the other page exists, is the version of the same content and also declares the first.

Part 4: what goes in and out of the index

5. Sitemap, robots.txt and noindex

These three tools are often confused, because all of them concern what Google does with pages. They answer different questions.

The sitemap is a list of URLs you want Google to know about. It helps discovery and does not guarantee indexing. The blog generates a sitemap-index.xml, which points to sitemap-0.xml with the pages. The documentation allows the sitemap to be submitted through Search Console and also referenced in robots.txt, and limits each file to 50 thousand URLs or 50 MB.

robots.txt is a file of crawling instructions, at the root of the domain. On the blog, it is generated by src/pages/robots.txt.ts from the configured domain, and the content is short: it allows everything with Allow: / and points to the Sitemap:. It has no Disallow, and that is a decision, not an oversight, as the next section explains.

noindex is a directive that lives inside the page, in the <meta name="robots" content="noindex"> tag, and tells Google not to show the page in search. On the blog, the base layout emits this meta when the page asks for it. Pages that should not appear on Google get the flag: the offline fallback, the 404, the personal highlights and reading list pages, page 2 onwards of listings and tags with few posts (section 6). Besides the meta, the build removes those URLs from the sitemap. The sitemap-noindex.mjs file reads which pages have noindex and removes from sitemap-0.xml both the URLs and the language links that point to them. The rule lives in one place, the page itself, so the sitemap never diverges from it.

The classic trap is blocking in robots.txt a page you want out of the index. The logic seems fine: if Google cannot get in, it does not index. But to read the noindex, Google has to crawl the page. The documentation is direct: if robots.txt blocks crawling, the information about indexing rules is not found and is ignored. The result is a page that keeps being listed, sometimes with only the address, and a directive nobody read. It is like hanging a “do not enter” sign on a door and locking it: the people who needed to read the sign never reach it.

That is why the blog’s plan handled /offline/ this way: it got noindex and left the sitemap, with no Disallow. The comment in robots.ts records the reason, and the buildRobots function does not mention NOINDEX_PATHS.

Decision tree: to take a page out of Google, use noindex without blocking crawling in robots.txt, because Google has to crawl to read the rule
figure 5 · Choosing between noindex and a robots.txt block for a page

The figure teaches the decision in order. The first diamond asks whether the intent is to take the page out of Google. If not, the page stays indexed and nothing needs to be done, as the green path on the right shows. If so, the next question is whether robots.txt blocks the page. Yes leads to the pink silent-error path, because the rule will never be read. No leads to the last diamond, which looks for the noindex meta. With the meta present, the page leaves the index, as the green on the left shows.

There is a consequence for hreflang. A page with noindex should not carry language links. Base.astro only emits the alternate tags when the page is not noindex, and the same goes for x-default. If the page left the index, declaring that it has an equivalent in another language would point to a destination Google will not show. Section 4 explains why hreflang only links indexable pages on both sides.

6. Canonical, duplicate content and thin content

The canonical is the <link rel="canonical" href="..."> tag that says which URL is preferred for a page, when several have similar content. The documentation treats it as a strong signal, not an order, and asks for absolute URLs. On the blog, each page declares itself with the configured domain. That sounds trivial, but there is an important rule: canonical is not for pointing the English version at the Portuguese one. The documentation says canonical annotations with language attributes are not used for canonicalization, and that Google treats localized variants as related, not as duplicates. Localizations only become duplicates when the main content stays in the original language.

Duplicate content is the case where two URLs serve the same text, for example a listing with the same page on two routes. Google decides which pages are duplicates by content similarity and picks one canonical URL among them, consolidating signals such as links on that choice. You can help with the canonical, but Google may disagree.

Thin content is the situation where the page has little text of its own. On the blog, most thin pages were tags with one or two posts, plus page 2 onwards of listings, which repeated the format of the first page with few differences. The OpenSEO audit flagged 126 pages with this problem. They stayed in the index and dragged down the site’s average quality.

The decision was made by volume, not by language. The rule is in isTagIndexable: a tag page only enters the index if it is the first page and the tag has at least three posts. The MIN_INDEXED_TAG_POSTS constant applies to both languages, so when the English version grows, the tags become indexed again without extra configuration. The same criterion decides hreflang: a tag only declares the pair with the other language when the version on the other side is also indexable.

When a tag page enters the index: the first page with at least three posts is indexable, and the others or the thin ones get noindex
figure 6 · When a tag page enters Google's index

The flow has two diamonds, and each negative answer leads to the same pink destination: noindex and out of the sitemap. The first question is whether the page is the first of the listing. The second is whether the tag has three or more posts. Only the green path reaches indexable, with its own canonical, and the last block shows the consequence for hreflang, which only applies when the pair is also indexable.

The decision has a cost worth recording. Series pages and the “about” page stay indexed, even when thin, because they are pages a person may look for on purpose. The plan also recorded that the rule has to be reassessed with real Search Console data, not only with the validator.

Part 5: data and domain

7. Structured data in and Open Graph

Structured data is information about the page in a format machines read without interpreting the text. JSON-LD is the form Google recommends: a <script type="application/ld+json"> block using the schema.org vocabulary. The article documentation accepts Article, NewsArticle and BlogPosting, and requires no property. It recommends headline, image, datePublished, dateModified and author, among others.

In each post, the blog emits a BlogPosting with headline, description, inLanguage, the dates, author, image and the keywords. The blogPostingSchema function builds this object from the frontmatter, and the jsonLdString function replaces < with \u003c before inserting the JSON into the HTML. That replacement stops a text containing </script> from closing the block midway.

Besides the BlogPosting, each post emits a BreadcrumbList, the structured navigation trail: Home, the series if there is one, and the title. The documentation requires itemListElement, with at least two items, and each item with position, name and item. For the last item the address is optional, but the blog includes it, and that causes no error.

Open Graph is another set of metadata, used by social networks and preview services, and it does not appear in the Google signal lists I consulted. On the blog, it handles three things: og:locale with the page’s language (pt_BR or en_US), og:locale:alternate when the other language’s version exists, and article metadata such as article:published_time and article:tag. The validator checks that og:locale matches the lang of <html>, because a mismatch between the two would send contradictory information to the reader and to any tool that reads it.

A post page with BlogPosting, BreadcrumbList and Open Graph language and article metas, checked by the local validator on every build
figure 7 · Structured data and Open Graph of a post page

The figure shows the page as the source of three outputs. The post page (orange) generates the BlogPosting and the BreadcrumbList as a script, and the Open Graph metas in the <head>. All of them land in the validator (green), which checks the project’s rule: a single BlogPosting, one BreadcrumbList and an og:locale consistent with the language. The arrows are short because the path is one of generation, not of crawling.

8. Domain, Search Console and sitemap

The page address is part of the content Google indexes. Canonical, hreflang, sitemap, RSS feed and the social image all carry the domain. If you change the domain after the page is in the index, Google has to relearn every URL, and the path recommended by the migration documentation is to redirect the old URLs to the new ones with a , keep the redirects for at least a year and update canonical and sitemap.

The blog started on the temporary CloudFront address, and the site configuration was switched to the jeangrijp.com.br domain. Canonicals, hreflang, the sitemap, robots.txt and the RSS now use the new domain, and DNS on Route 53 already pointed at the distribution. The plan recorded that the 301 would not be necessary, because the CloudFront address keeps responding and the annotations already point to the new domain. That conclusion depends on the temporary address never having been indexed, and the plan does not record that check. If the site has already been crawled through the old address, the redirect is once again the official recommendation.

To measure the site, Google asks you to register a property in Search Console. It can be by URL prefix or by domain. The covers all subdomains and protocols, and is verified by a TXT record in DNS. Search Console hands you a value that starts with google-site-verification=, and you register it with your DNS provider. On Route 53, the name field is left empty or set to @, and the TXT value has to be inside double quotes, according to the service’s own documentation. The AWS documentation requires those quotes, so the value has to be typed that way.

After verification, submit the sitemap-index.xml in the sitemaps report. That report shows when Googlebot read the file and whether there were processing errors. Keep in mind that data does not appear instantly. The documentation says the DNS record can take from a few minutes up to two or three days to be served, and that data starts to accumulate “for a few days” after verification.

On the blog, the TXT record was created on Route 53 on October 30, 2026 and the jeangrijp.com.br property was accepted the same day, after dig was already returning the value on public DNS. Right after verification, the performance report showed the message “Data is being processed: come back in about a day”, with no data yet. Connecting Search Console to the OpenSEO project was left for after that period, because URL inspection only has something to show once the data arrives.

Steps to switch the blog's domain and verify it in Google Search Console with a TXT record on Route 53 before submitting the sitemap
figure 8 · Switching domains and verifying in Google Search Console

The figure separates two responsibilities. On the left, the changes in the site itself (orange): switching the domain in the configuration, making canonical and sitemap use the new domain, and handling the old address. The dotted pink line between the new domain and the old one marks the point where the decision depends on a check: without a 301 redirect, the old address keeps responding as a second version of the site. On the right, the Search Console verification (blue and orange, with the last step green): Google hands over the record, Route 53 receives the TXT, verification happens, and only then is the sitemap submitted. The animated arrow on the last step represents submitting the sitemap.

Part 6: validating and avoiding pitfalls

9. How to validate

No tool sees the whole site. Each one looks at one angle, and relying on only one leaves problems out. The blog uses three, and the figure shows what each sees and what it lets through.

OpenSEO audits the public site, the way a crawler would. In the production audit, it found 1 critical problem (a broken internal link), 5 warnings and 5 informational items, among them repeated descriptions and titles, multiple H1s and thin content. It does not assess alt quality, hreflang or JSON-LD. It also does not crawl localhost, so validating what has not yet gone to production needs another tool.

pnpm check:seo is the local validator, the scripts/check-seo.mjs script. It runs after the build and reads dist/, which is the HTML that will be published. It is worth confirming the order: the project’s check script chains astro check, lint, formatting, tests, the build and, last, check:seo. In the latest run I did, over the dist/ generated today, the validator read 354 pages and 169 posts, 163 of them without a translation, and finished with 0 errors and 22 warnings, all about the description length of old posts. It checks for empty alt or alt equal to the file name, valid and present JSON-LD, og:locale, equality between HTML and sitemap hreflang, self-reference, a single H1, duplicate titles and descriptions, broken internal links and repeated translationKey.

Search Console shows what Google actually did: the indexed pages, the canonical it chose, the queries that brought visits. It is the only source that reflects Google’s decision, and the only one that arrives a few days late.

Three tools, three views: OpenSEO audits the public site, check:seo reads dist/ and Search Console shows real data; each one leaves something out
figure 9 · What each SEO tool sees on the blog

The figure lays out the three columns. In each, the top box says what the tool is, the middle box what it sees (green) and the bottom one what it does not see (pink). Notice that alt quality appears as a blind spot in two of them, and that Search Console is the only one with Google’s own data, with the delay that implies. The right reading combines the three: the validator catches the regression before the deploy, OpenSEO confirms what an outside crawler sees, and Search Console shows the final answer.

10. Lessons and pitfalls

The first pitfall is the build cache. Astro keeps the processed content of Markdown files in an internal store, and a change to a remark or rehype plugin does not always invalidate it. There is an issue in the Astro repository, #6245, about pages that do not update when a plugin changes. It was closed as “not planned” by the maintainer, and the solutions reported by the community are deleting the .astro folder or running the build with --force. This risk applies directly to this blog, because the diagram alt is generated by a plugin: if the SVG changes and the Markdown does not, the old HTML may keep the old alt. I found no case of this problem recorded on the blog, and the lesson does not claim it happened here. When an alt or hreflang fix does not show up in dist/, the first test is to rebuild with --force.

The second pitfall is the validator’s false positive, or false negative. The rule that rejects an alt equal to the file name lets through any alt with four words or more, because it assumes a title that long is a real title. That is a conscious choice, but it means a four-word alt that merely repeats the file passes without a warning. Another blind spot is in the script that reads the posts’ description: it looks for a single line starting with description:. If the description is multi-line YAML, the validator does not see it and does not warn. I did not find in the repository a false positive that was actually observed. The two points above are limits of the code, and they are worth checking when a warning looks odd or when a real problem passes silently.

The third pitfall is combining Disallow with noindex, already discussed in section 5. It is easy to commit because each line looks right on its own. The antidote is to decide on a single tool per goal: noindex to take a page out of search, robots.txt to control the crawling of resources that do not need to be read, and never both for the same page.

The fourth lesson is about the order of the heading plugin. The plugin that demotes H1s has to run after the plugins that look for H2 in the original structure. The code documents this in a comment, and the order is in astro.config.mjs. Swapping that order without thinking makes the table of contents and other features stop finding the sections.

Finally, the blog’s case shows that SEO fixes are phases, not a single file. The plan separated decisions (language, domain, alt format), fixes and checks, and marked each task as done only after verifying the result in dist/. That is the part you can take to any static site: decide before coding and verify in the generated artifact, not in the source code.

Exercises

  1. Open your project’s dist/sitemap-0.xml and count how many URLs have xhtml:link. Then check, for a translated post, whether the HTML’s link set equals the sitemap’s. If not, find out which translationKey is missing.
  2. Write a robots.txt with Disallow: /offline/ and another with the noindex meta on the /offline/ page. Explain, without consulting this lesson, why the second is the right way to take the page out of the index.
  3. Take a diagram SVG from your project and run the buildAlt logic on its title and description. See whether the resulting alt describes the figure for someone who cannot see it. If it does not, rewrite the SVG title, which is the source of the text.
  4. List your blog’s tag pages with fewer than three posts and say, for each one, whether it should have noindex and why. Then compare your answer with the isTagIndexable rule.

Glossary

  • Crawling: Googlebot downloading the HTML and resources of a URL.
  • Indexing: Google analyzing a page and storing it in the index, the base search answers from.
  • Ranking: the ordering of search results by relevance, done in the serving stage.
  • alt: alternative text of an image, used when it does not load, by screen readers and by Google.
  • hreflang: annotation that declares the language or region version of a page and its equivalents.
  • x-default: hreflang value for the page used when no language matches the visitor’s.
  • Canonical: tag that indicates the preferred URL among pages with similar content.
  • noindex: directive on the page that asks Google not to show it in search.
  • robots.txt: file at the domain root with crawling instructions.
  • Sitemap: list of URLs the site wants Google to know about.
  • JSON-LD: structured data format in a <script> block, using the schema.org vocabulary.
  • Domain property: Search Console registration that covers the whole domain, verified by a DNS record.
  • 301: permanent redirect status code from an old URL to a new one.

Sources

Consulted on October 30, 2026.

Measured in the blog repository on the consultation date: the excerpts of Base.astro, robots.ts, sitemap-links.mjs, sitemap-noindex.mjs, structured-data.ts, rehype-diagrams.mjs, rehype-demote-h1.mjs, indexing.ts, LangSwitch.astro and scripts/lib/seo-check.mjs were read, and the buildAlt function was run on a real blog SVG. pnpm check:seo was run on the existing dist/, generated on October 30, 2026, with the result described in section 9. I did not run the full build or Lighthouse, and I did not consult OpenSEO or Search Console, since they depend on an account and credits.

What I could not confirm: whether the temporary CloudFront address was ever indexed (that decides whether the 301 was needed); how long Search Console takes in total to show data on the blog (the initial message says about a day, and the real period has not been measured yet); whether the Astro cache caused any real problem in this project; and whether the 80 to 160 character range for alt improves the result, since none of the sources consulted defines an ideal length.

  • ARIA
  • SSG
  • CDN
  • Amazon CloudFront
  • Route 53
  • S3
  • AWS

↑ Back to top

Keyboard shortcuts

Open search
/
Toggle light/dark theme
t
Previous lesson in series
←
Next lesson in series
→
Shortcuts list
?