Skip to content

·  · 7 min read

The Astro SEO Setup Most Portfolios Skip

A title tag and a meta description aren't an SEO setup — they're the part every template already gives you. The plumbing that compounds (JSON-LD, canonicals that don't 404, hreflang that doesn't backfire, a clean sitemap) is what portfolios skip. Here's my full Astro SEO setup, real code included.

A title tag and a meta description aren't an SEO setup — they're the part every template already gives you. The plumbing that compounds (JSON-LD, canonicals that don't 404, hreflang that doesn't backfire, a clean sitemap) is what portfolios skip. Here's my full Astro SEO setup, real code included.

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:

  • dateModified must be honest. Falling back to datePublished when a post hasn’t been updated (like the code above) is correct. Faking a fresh dateModified on 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 indexed

Tags 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.

    Share:

    Enjoyed this post?

    Get new posts on web dev, AI and SEO straight to your inbox. No spam, unsubscribe anytime.

    No spam. By subscribing you agree to the privacy policy .

    Back to blog

    Related Posts

    View All Posts »
    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    A static Astro site with 15KB of JavaScript was shipping 3.4MB of images. Here are the three failure modes that got past the image pipeline — the fallback-always-ships rule, the screenshot the optimizer never touched, and the boilerplate nobody referenced — and the 15 lines of Sharp that fixed all of it.