Skip to content
Codeloom
Astro

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.

·7 min read · By Codeloom
Intermediate 13 min read

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)} />
---
// 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

  1. Always set width and height on images and videos.
  2. Reserve space for dynamic content with CSS min-height.
  3. Avoid inserting content above existing content after load.
  4. Use font-display: swap or optional for 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.