Table of Contents
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;
}
| Props | Description |
|---|---|
title | Page title |
description | Page description (auto-trimmed if over 160 characters) |
image | Featured image path |
lang | Current language (ja, ko, en) |
permalink | Page URL path |
isPost | Whether it’s a blog post (for BlogPosting JSON-LD output) |
date | Creation date (for posts) |
categoryPosts | Category post list (for ItemList JSON-LD output) |
noindex | Whether to exclude from search engine indexing |
isHome | Whether it’s the homepage (for WebSite JSON-LD output) |
isCategory | Whether 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
articlefor blog posts,websitefor 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(): Extractsdate,permalink, andlangfrom the frontmatter of all markdown files to create a date map per URL path. This ensures that each URL’slastmodis 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 fromlastmodMapand sets it aslastmod.i18n: Multilingual configuration that generates a sitemap withhreflangtags. 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.astrocomponent - Prevention of multilingual duplicate content with
canonicalURLs andhreflang - 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.
- Why I Migrated from Jekyll to Astro
- Astro Installation and Project Setup
- Content Collections and Markdown Migration
- Multilingual (i18n) Implementation
- SEO Implementation
- Image Optimization — Custom rehype Plugin
- Comment System (Utterances)
- Ad Integration (Google AdSense)
- Search Implementation with Pagefind
- Layout and Component Architecture
- GitHub Pages Deployment
- Social Share Automation Script
- Troubleshooting and Tips
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.