Almost every Astro portfolio and blog template ships with “SEO.” Open one up and you’ll find a <title>, a meta description, and an Open Graph image. That’s real, and it’s fine — but it’s also the part you get for free. It’s not a setup; it’s a default.
The Astro SEO setup that actually compounds over months is the plumbing underneath: structured data so search engines understand what each page is, canonicals that don’t point at a 404, hreflang that doesn’t quietly backfire on a multilingual site, a sitemap that only advertises pages you actually want indexed, and noindex on the thin pages that would otherwise dilute you. None of it is hard. All of it is skipped. Here’s the whole thing, with real code from this site — and two one-character mistakes I’ve made that can deindex a post without a single error.
1. Structured data: tell search engines what the page is
A search engine can read your HTML, but structured data (JSON-LD following schema.org) tells it, unambiguously: this is an Organization, this is a blog post, here’s the author, here’s the publish date. It’s what powers rich results and how your site gets understood as an entity rather than a bag of text.
Most templates ship none of it. Here’s what every post on this site emits — a BlogPosting node:
---
// src/components/common/StructuredData.astro (BlogPosting branch)
structuredData = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt || '',
image: postImage,
datePublished: post.publishDate.toISOString(),
dateModified: post.updateDate?.toISOString() || post.publishDate.toISOString(),
author: { '@type': 'Person', name: post.author, url: `${siteUrl}/about` },
publisher: {
'@type': 'Organization',
name: siteName,
logo: { '@type': 'ImageObject', url: `${siteUrl}/favicon.ico` },
},
mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
url: postUrl,
};
---
{Object.keys(structuredData).length > 0 && (
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
)}The homepage emits an Organization node (your logo, your social profiles via sameAs) and a WebSite node with a SearchAction — the thing that can give you a sitelinks search box. Post pages add a BreadcrumbList so the SERP shows your hierarchy instead of a raw URL.
Two rules that keep this from becoming a liability:
dateModifiedmust be honest. Falling back todatePublishedwhen a post hasn’t been updated (like the code above) is correct. Faking a freshdateModifiedon every deploy to look “recently updated” is a pattern Google has spent years learning to ignore.- Validate it once. Paste a live URL into Google’s Rich Results Test. A malformed JSON-LD node is worse than none — it can suppress the rich result entirely.
2. Canonicals: the one-character typo that deindexes a post
A canonical URL tells search engines “this is the real address of this page; consolidate all ranking signals here.” Every page should carry one, and for most pages it should point at itself:
---
// src/components/common/Metadata.astro
const {
canonical = String(getCanonical(String(Astro.url.pathname))),
} = Astro.props;
---
<AstroSeo canonical={canonical} ... />Deriving the canonical from the actual URL — instead of letting authors hardcode one per post — is the safe default, because the failure mode of a hardcoded canonical is brutal. I once shipped a post whose front-matter canonical had a single-letter typo: la-mejore-forma instead of la-mejor-forma. The page was live and fine, but its canonical pointed at a URL that didn’t exist. To Google, that post was declaring “the real version of me is over here” — at a 404. That’s a fast track to getting dropped from the index, and nothing in the build warns you.
The rule: let the canonical default to the page’s own URL, and only override it deliberately — for a syndicated cross-post, point it back at the original. If you do hardcode one, treat it like a production URL, because it is one.
3. hreflang: on a multilingual site, the wrong tag is worse than no tag
If you serve more than one language, hreflang tells Google which version to show which user. This site is primarily Spanish with English content under /en/, so each page advertises its alternates plus an x-default:
---
// src/components/common/Metadata.astro
const alternateUrls = getAlternateUrls(Astro.url);
---
{alternateUrls.map(({ locale, url }) => (
<link rel="alternate" hreflang={locale === 'es' ? 'es' : 'en'} href={url} />
))}
<link rel="alternate" hreflang="x-default"
href={alternateUrls.find((a) => a.locale === 'es')?.url || canonical} />Here’s the trap I hit, and it’s subtle: an English-only post has no Spanish version. If its hreflang still advertised an es alternate, that alternate would point at a URL that 404s — and a broken hreflang cluster gets the whole set ignored. So content that exists in only one language has to self-reference only:
// content that exists in exactly one language advertises only itself
if (isEnglishOnly(url.pathname)) {
return [{ locale: 'en', url: new URL(url.pathname, url.origin).toString() }];
}The general rule: hreflang must be reciprocal and every URL in it must return 200. An alternate pointing at a 404 doesn’t just fail for that page — it can invalidate the annotations for every page it references. If you can’t guarantee the other-language URL exists, don’t advertise it.
4. Sitemap hygiene: audit what you’re actually advertising
Generating a sitemap is table stakes — in Astro it’s one integration:
// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://heroweb.dev', // required, or URLs come out relative and useless
integrations: [sitemap()],
});The part people skip is reading their own sitemap. A sitemap is a list of pages you’re actively asking Google to crawl and index — so anything in it that you don’t want indexed is a self-inflicted quality problem. Auditing mine, I found a placeholder legal page — boilerplate “Company Name” terms text with a 2023 date — that I’d never linked in the nav but which was still being generated, and therefore still sitting in sitemap-index.xml, inviting Google to index fake legal copy on my domain.
Open https://yoursite.com/sitemap-index.xml (and the child sitemaps it points to) and read every URL. For anything that shouldn’t rank — placeholder pages, thin utility routes, duplicate paginations — either delete the page or noindex it, which brings us to the last piece.
5. noindex the thin stuff on purpose
More indexed pages is not better. Fifty near-empty tag-archive pages, each listing one post, is fifty thin pages competing with your actual content for crawl budget and quality signals. So this site indexes category archives but deliberately noindexes tag archives:
# src/config.yaml
apps:
blog:
tag:
isEnabled: true # tags still work for navigation
robots:
index: false # but they don't get indexedTags stay useful for readers navigating the site; they just don’t ask Google to rank a dozen one-item lists. Same logic applies to internal search results, paginated /page/2 archives, and anything auto-generated and thin. Index what you’d be proud to have someone land on cold. noindex the rest.
6. RSS that points home
RSS isn’t dead — it feeds readers, newsletters, and syndication platforms, and each of those is a place your content travels. The one thing that matters for SEO: every item links to its canonical URL, so wherever the feed gets republished, the ranking signal flows back to you:
// src/pages/rss.xml.ts
const postUrl = getCanonical(addLocaleToPath(getPermalink(post.permalink, 'post'), locale));
return { link: postUrl, title: post.title, description: post.excerpt || '', pubDate: post.publishDate };The real lesson: SEO for a small site is plumbing, not tricks
None of this is a growth hack. There’s no keyword-stuffing, no link scheme, no clever exploit. It’s five or six pieces of plumbing that each take an afternoon to wire correctly — and then the entire game becomes keeping them correct. The failure modes aren’t dramatic; they’re a typo’d canonical, an orphaned page in the sitemap, an hreflang pointing at a 404. Silent, individually small, and collectively the difference between a site that compounds and one that quietly leaks.
If you’re on an AstroWind-style theme, a lot of this is half-present and worth auditing rather than assuming. My deep-dive on the AstroWind template walks through how its config layers fit together, and if you want two concrete stories of a theme default doing something silently wrong, see the Partytown trap that killed my analytics and the breaking changes that bit my Astro 5→6 upgrade. Same theme, same lesson every time: the defaults are a starting point, not a finish line.
Go read your own sitemap. It takes five minutes, and you might find a placeholder terms page inviting Google to index copy you didn’t write.