Astro ships zero SEO defaults. There is no built-in title helper, no automatic canonical, no hreflang. Every tag in <head> is yours to emit. That sounds like a chore, but it is actually the win: you put the right setup in one <BaseHead> component, use it everywhere, and your whole site is correct by construction. This guide is the exact short list, with the current (June 2026) pixel limits and the head tags Google actually reads.
TL;DR
- Build one
<BaseHead>component, render it on every page, and never inline<title>or<meta>in individual pages. - The four non-negotiables: a unique
<title>, a<meta name="description">, a self-referencing absolute<link rel="canonical">, and<link rel="alternate" hreflang>if you have more than one language. - Size titles to roughly 50-60 characters (Google truncates desktop titles past ~600 px) and descriptions to 120-158 characters (~920 px desktop, ~680 px mobile).
- Generate the sitemap with the official
@astrojs/sitemapintegration, not by hand; pointrobots.txtat/sitemap-index.xml. - Verify the rendered HTML with
view-source:and the Rich Results Test before you trust it.
Why Astro makes you do this yourself
Frameworks like Next.js ship a Metadata API; Astro deliberately does not. You own the <head> element completely. The upside is that there is no hidden default fighting your tags, and the entire SEO surface for a content site is small enough to read in one component file. The downside is that “I forgot the canonical on the tag pages” is a self-inflicted bug, not a framework limitation. Centralizing every tag in <BaseHead> removes that entire failure class.
This is worth getting right early. At 500+ articles, retrofitting a missing tag means a re-crawl of the whole site and weeks of waiting for Google to re-process. One correct component on day one costs nothing later.
Who should follow this
- You are shipping any Astro site that depends on Google organic traffic.
- You have, or plan to have, more than one language version.
- You publish or update content regularly and cannot hand-check every page.
Skip it only for private internal tools where indexing is unwanted. In that case set <meta name="robots" content="noindex" /> globally and stop here.
The pixel limits that actually matter (June 2026)
Character counts are a guideline, not the rule. Google measures pixel width, and a W is far wider than an i. These are the desktop thresholds to write against:
| Tag | Sweet spot | Truncation point (desktop) | Mobile note |
|---|---|---|---|
<title> | 50-60 characters | ~600 px | Slightly tighter; front-load the keyword |
<meta name="description"> | 120-158 characters | ~920 px (~158 chars) | ~680 px (~120 chars) |
Sticking to 50-60 characters keeps titles intact in roughly 90% of desktop results. Two extra rules:
- Google rewrites your title or description when it thinks the query deserves something else, and bolded query-match words are wider, so leave a little headroom rather than maxing out the pixel budget.
- An empty description is worse than a short one. Google will auto-generate a snippet from page text, and it is usually clumsier than what you would write.
Step by step
1. Set site in astro.config.mjs
Every absolute URL in <head> (canonical, hreflang, OG image) is built from Astro.site. If it is unset, those tags silently come out wrong, and @astrojs/sitemap will not even generate.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
trailingSlash: 'always',
build: { format: 'directory' },
integrations: [
sitemap({
i18n: {
defaultLocale: 'en',
locales: { en: 'en', zh: 'zh-CN' },
},
}),
],
});
The i18n block makes @astrojs/sitemap emit hreflang annotations inside the sitemap itself, which is a second, sitemap-level signal on top of the head tags. defaultLocale must be one of the locales keys. (Google ignores changefreq and priority, so do not bother tuning them.)
2. Create src/components/BaseHead.astro
This is the only place meta tags are emitted in the entire site.
---
export interface Props {
title: string;
description: string;
lang?: 'en' | 'zh';
translationKey?: string;
ogImage?: string;
}
const { title, description, lang = 'en', translationKey, ogImage } = Astro.props;
if (!title || !description) {
throw new Error(`BaseHead requires title + description. Got: ${Astro.url.pathname}`);
}
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const ogImg = new URL(ogImage ?? '/og-default.png', Astro.site).toString();
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImg} />
<meta property="og:locale" content={lang === 'zh' ? 'zh_CN' : 'en_US'} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImg} />
{translationKey && (
<>
<link rel="alternate" hreflang="en"
href={new URL(`/en/articles/${translationKey}/`, Astro.site).toString()} />
<link rel="alternate" hreflang="zh"
href={new URL(`/zh/articles/${translationKey}/`, Astro.site).toString()} />
<link rel="alternate" hreflang="x-default"
href={new URL(`/en/articles/${translationKey}/`, Astro.site).toString()} />
</>
)}
The throw is deliberate. A build that fails loudly on a missing title beats a page that ships silently broken. The hreflang block emits all three variants on both the EN and ZH pages, which satisfies Google’s bidirectional rule (see the FAQ).
3. Wire it into your layout once
---
import BaseHead from '../components/BaseHead.astro';
const { title, description, lang, translationKey } = Astro.props;
---
<html lang={lang}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<BaseHead {title} {description} {lang} {translationKey} />
</head>
<body><slot /></body>
</html>
4. Point robots.txt at the sitemap
@astrojs/sitemap writes sitemap-index.xml plus one or more sitemap-0.xml chunks to dist/ on build, and auto-splits past 50,000 URLs. Tell crawlers where the index lives in public/robots.txt:
User-agent: *
Allow: /
Sitemap: https://yourdomain.com/sitemap-index.xml
5. Confirm the rendered HTML
Open view-source: on a deployed article. You are looking for exactly this shape:
<title>Astro SEO Basics: Title, Meta, Canonical, Hreflang</title>
<meta name="description" content="The minimum-viable SEO setup..." />
<link rel="canonical" href="https://yourdomain.com/en/articles/astro-seo-basics/" />
<meta property="og:title" content="Astro SEO Basics..." />
<link rel="alternate" hreflang="en" href="https://yourdomain.com/en/articles/astro-seo-basics/" />
<link rel="alternate" hreflang="zh" href="https://yourdomain.com/zh/articles/astro-seo-basics/" />
<link rel="alternate" hreflang="x-default" href="https://yourdomain.com/en/articles/astro-seo-basics/" />
6. Audit production in one command
Any URL missing canonical or the three hreflang lines prints below:
for u in $(cat sitemap-urls.txt); do
curl -s "$u" | grep -E 'rel="canonical"|hreflang=' \
| wc -l | awk -v u=$u '$1 < 4 { print u, "missing tags" }'
done
(Self + EN + ZH + x-default = 4 matching lines on a bilingual page; tune the threshold to your tag count.)
7. Validate and request indexing
Plug a representative canonical URL into Google’s Rich Results Test. It parses the live <head> and surfaces silent errors: a broken canonical, malformed OG, or a missing alternate. Then open Search Console’s URL Inspection tool, run it on the homepage and one article, and click “Request indexing” instead of waiting for the next crawl.
Common pitfalls
- Inlining
<title>in individual pages. Works for ten pages, breaks at a hundred when someone forgets one.<BaseHead>is the cure. - Relative canonical URLs. Only the absolute form is safe;
new URL(pathname, Astro.site)guarantees it. - Non-reciprocal hreflang. If the EN page lists ZH but the ZH page does not list EN, Google drops the whole cluster. Emit all variants on every page.
- Forgetting
x-default. Without it Google guesses which language to serve, often wrongly. - Inconsistent OG vs. Twitter values. Derive both from the same
title/descriptionprops so they cannot drift. - Setting meta tags client-side with JavaScript. Crawlers may never run it. Astro renders the head at build time, which is exactly what you want.
FAQ
- Do I need structured data on day one? No. A unique title, description, canonical, and hreflang are enough to get indexed cleanly. Layer in
ArticleandFAQPageJSON-LD once the basics are stable and pages are getting impressions. - Should canonical always point to the current URL? Self-canonical is the safe default, and Google recommends it. The exceptions are deliberate duplicates: paginated lists can point page 2+ at page 1, and a
noindexpage should omit canonical entirely. - How long should a title be in 2026? Aim for 50-60 characters. Google truncates desktop titles past roughly 600 px, and 50-60 characters renders fully in about 90% of results. Front-load the keyword so it survives truncation.
- Does hreflang need a self-referencing tag? Google now treats the self-reference as optional, but it is good practice and makes the set easier to reason about, so keep it. What is not optional is bidirectional reciprocity: every variant must reference every other variant, or the cluster fails.
- Do
changefreqandpriorityin the sitemap help? No. Google has stated it ignores both. Leave them at defaults and spend the effort on the head tags instead. - Should I use the
astro-seopackage instead of a custom<BaseHead>? For a content site the custom component is usually better: it is ~40 lines, has no dependency to track, and you control every tag. Reach forastro-seoonly if you want a typed prop surface and do not mind the abstraction.
Related
- Setting up sitemap.xml properly in Astro
- Building category and tag pages in Astro
- Building a Markdown / MDX content site that scales
- How to Use AI to Audit an Astro Content Site (Without Reading Every File)
- SEO Title Prompts: 15 Templates for Click-Worthy Search Titles
- Deploying an Astro Site to Firebase Hosting
Tags: #Indie dev #Astro #SEO #Technical SEO #Canonical #hreflang