Astro has a major advantage in the SEO world: speed. The Core Web Vitals of a site built with Astro are usually good by default, and Google loves that.
However, performance is only half the battle. The other half is communication with crawlers.
We need to tell Google, Bing, Facebook, and Twitter exactly what title to display, which image to use in the preview, and what the “official” URL is for each piece of content.
In this article, we’ll see how to optimize our site for SEO without dying in the attempt.
Managing Metadata
In small projects, it’s common to define meta tags directly in the Layout.astro file:
<title>My Blog</title>
<meta name="description" content="A blog about technology..." />
But what happens when you’re on a specific article? You want the title to be that of the post, not “My Blog”. You want the Twitter image to be the article’s cover, etc…
If you start putting if/else conditionals inside the <head> of the Layout, you’ll end up with code that’s hard to understand. The solution is to create an SEO Component.
Creating the <SEO /> Component
Let’s create a component dedicated exclusively to injecting meta tags. We’ll save it in src/components/SEO.astro.
The first step is to define which data can change from one page to another.
---
// src/components/SEO.astro
interface Props {
title: string;
description: string;
image?: string;
canonicalURL?: URL | string;
type?: 'website' | 'article'; // For Open Graph
}
const {
title,
description,
image = '/img/default-og.png', // Default image
canonicalURL = new URL(Astro.url.pathname, Astro.site),
type = 'website'
} = Astro.props;
// Ensure the image is an absolute URL (necessary for OG tags)
const absoluteImageUrl = new URL(image, Astro.site);
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={absoluteImageUrl} />
<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={absoluteImageUrl} />
Important: Configure Astro.site
For new URL(path, Astro.site) to work and generate absolute URLs (e.g., https://mywebsite.com/image.png), it is mandatory to define your domain in astro.config.mjs:
// astro.config.mjs
export default defineConfig({
site: 'https://www.luisllamas.es', // Your real domain
});
Using the Component in the Layout
Now we clean up our Layout.astro and use our new component.
---
// src/layouts/Layout.astro
import SEO from '../components/SEO.astro';
interface Props {
title: string;
description?: string;
image?: string;
}
const { title, description, image } = Astro.props;
---
<html lang="es">
<head>
<meta charset="UTF-8" />
<SEO
title={title}
description={description || "Default website description"}
image={image}
/>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
And on a blog page ([...slug].astro), we simply pass the data:
---
const { post } = Astro.props;
---
<Layout
title={post.data.title}
description={post.data.description}
image={post.data.cover}
>
</Layout>
Canonical URLs
Canonical tags (rel="canonical") are essential to avoid Google penalizing you for duplicate content.
Sometimes, the same page is accessible from multiple URLs:
https://mywebsite.com/blog/post-1https://mywebsite.com/blog/post-1?utm_source=twitter
For Google, these are two different pages with the same text, and that is heavily penalized. In our SEO component, we have already automated this:
canonicalURL = new URL(Astro.url.pathname, Astro.site)
This takes the clean path (/blog/post-1), ignores query parameters (?utm...), and builds the official URL. Astro takes care of normalizing it (for example, removing or adding the trailing slash / according to your configuration).
Sitemap Generation (sitemap.xml)
A sitemap is an XML file that lists all the pages on your site so search engines can find them quickly. Doing this manually is impossible on dynamic sites.
Astro has a wonderful official integration for this.
npx astro add sitemap
This will install @astrojs/sitemap and update your configuration.
The integration will automatically read all your static routes (src/pages) and routes generated by getStaticPaths.
The file will be generated at dist/sitemap-index.xml (or sitemap-0.xml) when you build.
The sitemap requires the site property to be configured in astro.config.mjs. If you don’t put your domain, sitemap generation will fail because sitemaps require absolute URLs.
Structured Data (JSON-LD)
If you want to go for the gold, you can inject Schema.org so Google shows rich results (stars, recipes, events).
We can add this to our SEO.astro component or directly in the article layout:
---
// In a post layout
const schema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": title,
"image": absoluteImageUrl,
"author": {
"@type": "Person",
"name": "Luis Llamas"
},
"datePublished": pubDate.toISOString()
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
We use set:html to inject the JSON string directly inside the script tag without Astro trying to escape it.
