[Astro] SEO Implementation

2026-03-28 hit count image

Sharing how to implement SEO (Search Engine Optimization) in an Astro blog. Covers meta tags, Open Graph, JSON-LD structured data, sitemaps, RSS, Google Analytics, and more.

astro

Overview

In the previous post Multilingual (i18n) Implementation, we implemented a multilingual system supporting 3 languages. In this post, we’ll cover how to implement various SEO (Search Engine Optimization) techniques in Astro.

All SEO-related logic is concentrated in the src/components/Head.astro component, allowing management from a single location.

Head.astro Component

Head.astro is the component responsible for the <head> section of every page. It receives page information via Props and generates appropriate meta tags and structured data.

// src/components/Head.astro
interface Props {
  title: string;
  description: string;
  image?: string;
  lang: Lang;
  permalink: string;
  isPost?: boolean;
  date?: string;
  categoryPosts?: CategoryPostItem[];
  noindex?: boolean;
  category?: string;
  isHome?: boolean;
  isCategory?: boolean;
}
PropsDescription
titlePage title
descriptionPage description (auto-trimmed if over 160 characters)
imageFeatured image path
langCurrent language (ja, ko, en)
permalinkPage URL path
isPostWhether it’s a blog post (for BlogPosting JSON-LD output)
dateCreation date (for posts)
categoryPostsCategory post list (for ItemList JSON-LD output)
noindexWhether to exclude from search engine indexing
isHomeWhether it’s the homepage (for WebSite JSON-LD output)
isCategoryWhether it’s a category page (for CollectionPage JSON-LD output)

Different Props are passed depending on the page type to generate appropriate SEO tags.

Basic Meta Tags

<meta charset="utf-8" />
<meta
  name="viewport"
  content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="description" content="{description}" />
<meta name="author" content="dev-yakuza" />
<title>{title} | DeKu</title>

The description is automatically trimmed if it exceeds 160 characters. This is to match the length limit displayed in search results.

const description =
  rawDescription && rawDescription.length > 160
    ? rawDescription.slice(0, 160)
    : rawDescription;

Canonical URL and hreflang

These are the most important SEO elements for a multilingual site.

Canonical URL

<link rel="canonical" href="{canonicalUrl}" />

The canonical tag specifies the canonical URL of the current page. It prevents duplicate content issues and tells search engines “this URL is the primary URL for this page.”

hreflang

<link rel="alternate" hreflang="ja" href={`${siteUrl}${rawPermalink}`} />
<link rel="alternate" hreflang="ko" href={`${siteUrl}/ko${rawPermalink}`} />
<link rel="alternate" hreflang="en" href={`${siteUrl}/en${rawPermalink}`} />
<link rel="alternate" hreflang="x-default" href={`${siteUrl}${rawPermalink}`} />

The hreflang tag tells search engines about other language versions of the same content. For example, searches from Japan will show the Japanese version, while searches from Korea will show the Korean version in results.

x-default specifies the default page to show to users who don’t match any language.

rawPermalink is the path with the language prefix removed.

const rawPermalink = permalink
  .replace(/^\/(ko|en)\//, '/')
  .replace(/^\/(ko|en)$/, '/');

Open Graph Tags

Controls the information displayed when sharing links on social media.

<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content={isPost ? 'article' : 'website'} />
<meta property="og:site_name" content="DeKu" />
<meta property="og:locale" content={localeMap[lang]} />

Key Points

Here are the key Open Graph tags:

  • og:type: Set to article for blog posts, website for others
  • og:locale: Locale mapping per language (ja_JP, ko_KR, en_US)
  • og:locale:alternate: Also provides locales for other language versions
  • og:image: Uses the site icon as default when no image is available

For posts, additional information is also included.

{isPost && date && <meta property="article:published_time" content="{date}" />}
{isPost && <meta property="article:author" content="dev-yakuza" />} {isPost &&
category && <meta property="article:section" content="{category}" />}

Twitter Cards

Specifies the card format when sharing links on Twitter (X).

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@DevYakuza" />
<meta name="twitter:creator" content="@DevYakuza" />
<meta name="twitter:title" content="{title}" />
<meta name="twitter:description" content="{description}" />
<meta name="twitter:image" content="{ogImage}" />
<meta name="twitter:image:alt" content="{title}" />

Using the summary_large_image card type displays the card with a large image.

JSON-LD Structured Data

Provides structured data so search engines can understand page content more accurately. Uses JSON-LD format and outputs different schemas depending on the page type.

Person (All Pages)

Links the author’s social media profiles to provide author information to search engines.

{
  "@context": "https://schema.org",
  "@type": "Person",
  "name": "dev-yakuza",
  "url": "https://deku.posstree.com",
  "sameAs": [
    "https://github.com/dev-yakuza",
    "https://www.facebook.com/yakuza.dev.54",
    "https://www.instagram.com/dev_yakuza/",
    "https://twitter.com/DevYakuza"
  ]
}

Organization (All Pages)

Provides the site’s (or organization’s) basic information to search engines. Includes the site name, URL, and logo image to ensure brand information is displayed accurately in search results.

{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "DeKu",
  "url": "https://deku.posstree.com",
  "logo": "https://deku.posstree.com/assets/images/icon.jpg"
}

BlogPosting (Post Pages)

Configured as follows to enable display as rich snippets in Google search results.

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Post Title",
  "description": "Post Description",
  "datePublished": "2026-04-29T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "dev-yakuza",
    "url": "https://deku.posstree.com"
  },
  "publisher": {
    "@type": "Organization",
    "name": "DeKu",
    "logo": {
      "@type": "ImageObject",
      "url": "https://deku.posstree.com/assets/images/icon.jpg"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://deku.posstree.com/en/astro/seo/"
  }
}

WebSite (Homepage)

Including SearchAction may enable a site search box to appear in Google search results.

{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "DeKu",
  "url": "https://deku.posstree.com",
  "inLanguage": ["ja", "ko", "en"],
  "potentialAction": {
    "@type": "SearchAction",
    "target": {
      "@type": "EntryPoint",
      "urlTemplate": "https://deku.posstree.com/search/?q={search_term_string}"
    },
    "query-input": "required name=search_term_string"
  }
}

ItemList (Category Pages)

Passes the list of posts belonging to a category page as an ordered list to search engines. Includes each item’s order (position), URL, and title so search engines can understand the content structure within the category.

{
  "@context": "https://schema.org",
  "@type": "ItemList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "url": "...", "name": "..." },
    { "@type": "ListItem", "position": 2, "url": "...", "name": "..." }
  ]
}

CollectionPage (Category Pages)

Tells search engines that this page is a collection page aggregating multiple pieces of content. Includes the category name, description, and URL so search engines correctly recognize this page as a content collection rather than an individual post.

{
  "@context": "https://schema.org",
  "@type": "CollectionPage",
  "name": "Category Name",
  "description": "Category Description",
  "url": "https://deku.posstree.com/en/astro/"
}

Sitemap

The @astrojs/sitemap plugin automatically generates a multilingual sitemap. This blog uses a method where content files are read at build time to create a date map per URL, so that the date field from each post’s frontmatter is used as the lastmod.

// astro.config.mjs
import sitemap from '@astrojs/sitemap';
import fs from 'node:fs';

/** Build a map of URL path → lastmod date from content frontmatter */
function buildLastmodMap() {
  const map = new Map();
  const files = fs.globSync('src/content/**/*.md');
  for (const file of files) {
    const content = fs.readFileSync(file, 'utf-8');
    const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
    if (!match) continue;
    const fm = match[1];
    const dateMatch = fm.match(/^date:\s*['"]?(\d{4}-\d{2}-\d{2})['"]?/m);
    const permalinkMatch = fm.match(/^permalink:\s*['"]?([^\s'"]+)['"]?/m);
    const langMatch = fm.match(/^lang:\s*['"]?(\w+)['"]?/m);
    if (!dateMatch || !permalinkMatch || !langMatch) continue;
    const date = new Date(dateMatch[1]);
    const permalink = permalinkMatch[1];
    const lang = langMatch[1];
    const prefix = lang === 'ja' ? '' : `/${lang}`;
    const path = `${prefix}${permalink}`;
    map.set(path, date);
  }
  return map;
}

const lastmodMap = buildLastmodMap();

export default defineConfig({
  integrations: [
    sitemap({
      serialize(item) {
        const url = new URL(item.url);
        const lastmod = lastmodMap.get(url.pathname);
        if (lastmod) {
          item.lastmod = lastmod;
        }
        return item;
      },
      i18n: {
        defaultLocale: 'ja',
        locales: {
          ja: 'ja',
          ko: 'ko',
          en: 'en',
        },
      },
    }),
  ],
});
  • buildLastmodMap(): Extracts date, permalink, and lang from the frontmatter of all markdown files to create a date map per URL path. This ensures that each URL’s lastmod is set to the actual creation date of the post, not the current date.
  • serialize: For each URL in the sitemap, looks up the corresponding date from lastmodMap and sets it as lastmod.
  • i18n: Multilingual configuration that generates a sitemap with hreflang tags. This allows search engines to recognize language-specific versions of each page.

During build, files like dist/sitemap-index.xml and dist/sitemap-0.xml are automatically generated.

RSS Feed

Language-specific RSS feeds are generated using the @astrojs/rss package.

npm install @astrojs/rss

Separate RSS feed files are generated for each language.

  • Japanese: /feed.xml
  • Korean: /ko/feed.xml
  • English: /en/feed.xml

Head.astro provides the RSS link matching the current language.

<link
  rel="alternate"
  type="application/rss+xml"
  title="DeKu RSS Feed"
  href="{rssPath}"
/>
const rssPath = getLocalizedPath('/feed.xml', lang);

Google Analytics

Google Analytics 4 (GA4) is integrated to collect visitor statistics.

<script
  async
  src="https://www.googletagmanager.com/gtag/js?id=G-FR62E3SBVX"
></script>
<script is:inline>
  window.dataLayer = window.dataLayer || [];
  function gtag() {
    dataLayer.push(arguments);
  }
  gtag('js', new Date());
  gtag('config', 'G-FR62E3SBVX');
</script>

Using the is:inline directive makes Astro include the script directly in the HTML without bundling. Since external analytics scripts don’t need bundling, is:inline is used.

robots.txt and Search Engine Registration

robots.txt

The public/robots.txt file controls search engine crawler access.

User-agent: *
Allow: /
Sitemap: https://deku.posstree.com/sitemap-index.xml

Allows all crawlers access to the entire site and indicates the sitemap location.

Search Engine Registration

Verification files for registering the site with Google Search Console and Naver Webmaster Tools are placed in the public/ directory.

public/
├── googleXXXXXXXXXXXXXXXX.html    # Google verification file
└── naverXXXXXXXXXXXXXXXX.html     # Naver verification file

Additionally, verification is also done via meta tags in Head.astro.

<meta
  name="google-site-verification"
  content="rHTd8BRbOeSqAskkyVusn529XiwZ2eHbv1tnB1IDnjA"
/>

Conclusion

In this post, we looked at the SEO implementation of the Astro blog.

  • Centralized management of all SEO meta tags in the Head.astro component
  • Prevention of multilingual duplicate content with canonical URLs and hreflang
  • Social media sharing optimization with Open Graph and Twitter Cards
  • Rich snippet support with JSON-LD structured data (Person, BlogPosting, WebSite, CollectionPage, ItemList)
  • Automatic multilingual sitemap generation with @astrojs/sitemap
  • Language-specific RSS feeds with @astrojs/rss
  • Google Analytics 4 (GA4) integration

Compared to SEO implementation in Jekyll, Astro allows type-safe management of SEO tags through a component-based approach, making maintenance much easier. For the previous Jekyll SEO implementation, refer to the Jekyll SEO post.

In the next post Image Optimization — Custom rehype Plugin, we’ll cover implementing an AVIF/WebP auto-conversion plugin using Sharp.

Series Guide

This post is part of the Jekyll to Astro migration series.

  1. Why I Migrated from Jekyll to Astro
  2. Astro Installation and Project Setup
  3. Content Collections and Markdown Migration
  4. Multilingual (i18n) Implementation
  5. SEO Implementation
  6. Image Optimization — Custom rehype Plugin
  7. Comment System (Utterances)
  8. Ad Integration (Google AdSense)
  9. Search Implementation with Pagefind
  10. Layout and Component Architecture
  11. GitHub Pages Deployment
  12. Social Share Automation Script
  13. Troubleshooting and Tips

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS