SEO and Performance Optimization in Astro
A practical guide to Astro SEO: meta tags, Open Graph, structured data, sitemaps, canonical URLs, and Core Web Vitals optimization techniques.
What you'll learn
- ✓How to build a reusable SEO component for meta tags and Open Graph
- ✓How to generate a sitemap and robots.txt with Astro integrations
- ✓How to add JSON-LD structured data for rich search results
- ✓How to set canonical URLs and handle duplicate content
- ✓Performance techniques that directly improve Core Web Vitals
Prerequisites
- •Basic Astro project setup
- •HTML meta tag basics
Why Astro is Already Good at SEO
Astro ships static HTML by default. No JavaScript-rendered content for crawlers to miss. No hydration delay inflating LCP. No layout shifts from client-side rendering. You start with a good baseline. This article is about pushing that baseline into excellent territory.
The SEO Component
Create a reusable component that every page includes in its <head>:
---
// src/components/SEO.astro
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
publishedAt?: Date;
updatedAt?: Date;
noindex?: boolean;
canonical?: string;
}
const {
title,
description,
image = '/og-default.png',
type = 'website',
publishedAt,
updatedAt,
noindex = false,
canonical,
} = Astro.props;
const siteUrl = import.meta.env.SITE ?? 'https://example.com';
const canonicalUrl = canonical ?? new URL(Astro.url.pathname, siteUrl).href;
const ogImageUrl = new URL(image, siteUrl).href;
const siteName = 'Codeloom';
---
<!-- Primary Meta Tags -->
<title>{title} | {siteName}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<!-- Open Graph -->
<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={ogImageUrl} />
<meta property="og:site_name" content={siteName} />
{publishedAt && <meta property="article:published_time" content={publishedAt.toISOString()} />}
{updatedAt && <meta property="article:modified_time" content={updatedAt.toISOString()} />}
<!-- Twitter Card -->
<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={ogImageUrl} />
Use it in your layout:
---
// src/layouts/Base.astro
import SEO from '@/components/SEO.astro';
const { title, description, image, publishedAt } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<SEO {title} {description} {image} {publishedAt} />
</head>
<body>
<slot />
</body>
</html>
Dynamic OG Images
Generate Open Graph images at build time using @astrojs/og:
// src/pages/og/[slug].png.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { title: post.data.title, category: post.data.category },
}));
}
export const GET: APIRoute = async ({ props }) => {
// Using satori or a canvas library to generate images
const html = `
<div style="display:flex;flex-direction:column;justify-content:center;
padding:60px;width:1200px;height:630px;background:#0f172a;color:white;
font-family:sans-serif;">
<div style="font-size:24px;color:#94a3b8;margin-bottom:20px;">
${props.category}
</div>
<div style="font-size:56px;font-weight:bold;line-height:1.2;">
${props.title}
</div>
<div style="font-size:20px;color:#64748b;margin-top:auto;">
codeloom.dev
</div>
</div>
`;
// Use a library like satori + resvg to convert HTML to PNG
// This is a simplified example
const png = await generateOgImage(html);
return new Response(png, { headers: { 'Content-Type': 'image/png' } });
};
Sitemap
Install the official sitemap integration:
npx astro add sitemap
Configure it in astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://codeloom.dev',
integrations: [
sitemap({
filter: (page) => !page.includes('/admin/') && !page.includes('/draft/'),
changefreq: 'weekly',
priority: 0.7,
customPages: ['https://codeloom.dev/external-page'],
serialize(item) {
// Boost priority for blog index
if (item.url === 'https://codeloom.dev/blog/') {
item.priority = 0.9;
item.changefreq = 'daily';
}
return item;
},
}),
],
});
Add a reference in your robots.txt:
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://codeloom.dev/sitemap-index.xml
Structured Data with JSON-LD
Structured data gives search engines explicit information about your content. Articles, FAQs, and breadcrumbs are the most impactful types.
Article Schema
---
// src/components/ArticleSchema.astro
interface Props {
title: string;
description: string;
publishedAt: Date;
updatedAt?: Date;
author: string;
image: string;
}
const { title, description, publishedAt, updatedAt, author, image } = Astro.props;
const siteUrl = import.meta.env.SITE;
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
image: new URL(image, siteUrl).href,
datePublished: publishedAt.toISOString(),
dateModified: (updatedAt ?? publishedAt).toISOString(),
author: {
'@type': 'Person',
name: author,
},
publisher: {
'@type': 'Organization',
name: 'Codeloom',
logo: {
'@type': 'ImageObject',
url: new URL('/logo.png', siteUrl).href,
},
},
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
Breadcrumb Schema
---
// src/components/BreadcrumbSchema.astro
interface BreadcrumbItem {
name: string;
url: string;
}
interface Props {
items: BreadcrumbItem[];
}
const { items } = Astro.props;
const siteUrl = import.meta.env.SITE;
const schema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: new URL(item.url, siteUrl).href,
})),
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
FAQ Schema
---
// src/components/FAQSchema.astro
interface FAQ {
question: string;
answer: string;
}
interface Props {
faqs: FAQ[];
}
const { faqs } = Astro.props;
const schema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
Performance Optimization
Image Optimization
Use Astro’s built-in <Image> component. It generates responsive sizes, converts to WebP/AVIF, and adds width/height to prevent CLS:
---
import { Image } from 'astro:assets';
import heroImage from '@/assets/hero.jpg';
---
<Image
src={heroImage}
alt="Hero image description"
widths={[400, 800, 1200]}
sizes="(max-width: 600px) 400px, (max-width: 900px) 800px, 1200px"
loading="eager"
fetchpriority="high"
/>
For the LCP image (usually the hero), set loading="eager" and fetchpriority="high". For everything else, let the default lazy loading handle it.
Font Loading
Fonts are a common LCP blocker. Self-host them and preload the critical ones:
<!-- In your <head> -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
<style is:global>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
</style>
font-display: swap prevents invisible text while the font loads.
Critical CSS
Astro automatically scopes component styles and inlines them in the HTML. For global styles, keep them small:
---
// src/layouts/Base.astro
---
<style is:global>
/* Only critical above-the-fold styles here */
:root {
--bg: #0f172a;
--text: #e2e8f0;
--accent: #38bdf8;
}
body {
margin: 0;
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
</style>
<!-- Non-critical styles loaded asynchronously -->
<link rel="stylesheet" href="/styles/global.css" media="print" onload="this.media='all'" />
Prefetching
Astro supports link prefetching to make navigations feel instant:
// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: false,
defaultStrategy: 'viewport', // Prefetch links as they enter the viewport
},
});
You can also control it per link:
<a href="/blog" data-astro-prefetch="hover">Blog</a>
<a href="/about" data-astro-prefetch="viewport">About</a>
<a href="/heavy-page" data-astro-prefetch="false">Heavy Page</a>
Minimizing CLS
- Always set
widthandheighton images and videos. - Reserve space for dynamic content with CSS
min-height. - Avoid inserting content above existing content after load.
- Use
font-display: swaporoptionalfor web fonts.
/* Reserve space for an ad slot */
.ad-container {
min-height: 250px;
background: var(--bg-muted);
}
Canonical URLs and Duplicate Content
If the same content appears at multiple URLs (with/without trailing slash, www/non-www, paginated), set a canonical URL:
<link rel="canonical" href={Astro.url.href} />
Configure trailing slash behavior in your Astro config:
export default defineConfig({
trailingSlash: 'never', // or 'always' or 'ignore'
});
Summary
Astro gives you a fast, crawlable site by default. A reusable SEO component ensures consistent meta tags across all pages. Structured data with JSON-LD earns rich search results. The sitemap integration handles XML generation. And performance optimizations like image compression, font preloading, and prefetching push your Core Web Vitals into the green. The key insight is that Astro already does the hard work — you just need to not undo it.
Related articles
- Astro Astro Islands Architecture: Patterns and Anti-Patterns
Advanced island patterns in Astro: when to hydrate, how to share state between islands, mixing frameworks, lazy loading strategies, and mistakes that kill performance.
- Astro Astro Image Component and Optimization
Master Astro's built-in Image and Picture components to ship responsive, lazy-loaded, modern-format images without external services.
- Astro Astro Islands Architecture Explained
Learn how Astro ships zero JavaScript by default and only hydrates the interactive components you mark as islands.
- Next.js Next.js SEO: Meta Tags, Sitemap, and Structured Data
Optimize your Next.js app for search engines with the Metadata API, dynamic Open Graph images, sitemaps, robots.txt, and JSON-LD structured data.