
How to add SEO meta tags in Astro?
To add SEO meta tags in Astro, create a small Seo.astro component that accepts a title, description and image as props and renders the <title>, <meta name="description">, canonical link, Open Graph and Twitter tags. Include it in the <head> of your base layout and pass page-specific values from each page or from your content collection frontmatter. Set the site option in astro.config.mjs so you can build absolute URLs with new URL(Astro.url.pathname, Astro.site), because canonical links and social images must use full URLs.
Astro doesn't need a plugin for this. Because every page is rendered to HTML on the server, whatever you put in the <head> is exactly what search engines and social platforms see, with no client-side rendering to wait for. In this article you'll learn which tags matter, how to build a typed Seo component, how to wire it into layouts and content collections, how to handle canonical URLs and trailing slashes, how to keep pages out of the index, and how to check the result.
The Tags That Matter
There are dozens of possible meta tags, but only a handful affect search results and link previews:
| Tag | Purpose | Notes |
|---|---|---|
<title> | The page title in search results and browser tabs | Unique per page, roughly 50-60 characters |
<meta name="description"> | Often used as the search snippet | Unique per page, roughly 140-160 characters |
<link rel="canonical"> | The preferred URL for this content | Must be an absolute URL |
<meta name="robots"> | Indexing instructions | Only needed to restrict, such as noindex |
og:title, og:description, og:image, og:url, og:type | Link previews on social platforms and chat apps | og:image must be absolute |
twitter:card | The preview style on X | summary_large_image for a big image |
<html lang> | The page language | Set on the <html> element in your layout |
The meta keywords tag is ignored by major search engines, so you can leave it out.
Set the site Option First
Several tags need absolute URLs. Astro builds them from the site option, which you should set to your production domain:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
});
Once site is set, Astro.site is a URL object in every component, and integrations such as the sitemap can use it too. Without it, Astro.site is undefined and any URL built from it will be wrong or will throw.
Building a Reusable Seo Component
Put all of the head tags in one component so every page gets a consistent set:
---
// src/components/Seo.astro
interface Props {
title: string;
description: string;
image?: string;
type?: "website" | "article";
noindex?: boolean;
publishedTime?: Date;
}
const {
title,
description,
image = "/og-default.png",
type = "website",
noindex = false,
publishedTime,
} = Astro.props;
const siteName = "Example Blog";
const fullTitle = title === siteName ? title : `${title} | ${siteName}`;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const imageURL = new URL(image, Astro.site);
---
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
{noindex && <meta name="robots" content="noindex, follow" />}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={siteName} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={imageURL} />
{publishedTime && (
<meta property="article:published_time" content={publishedTime.toISOString()} />
)}
<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={imageURL} />
A few details are worth pointing out:
- Typed props: the
Propsinterface means a page that forgetsdescriptionfailsastro check, which is a cheap way to make sure no page ships without one. - URL objects as attributes: Astro converts a
URLto its string form when you pass it as an attribute, so there's no need to call.href. - Image paths:
new URL(image, Astro.site)turns a root-relative path such as/og-default.pnginto a full URL, and leaves an already absolute URL unchanged. - Title template: the site name is added to every title except the home page, where it would be repeated.
Using the Component in a Layout
Include the component in your base layout's <head> and forward the layout's props to it. ComponentProps from astro/types lets the layout reuse the component's prop types instead of repeating them:
---
// src/layouts/BaseLayout.astro
import Seo from "../components/Seo.astro";
import type { ComponentProps } from "astro/types";
type Props = ComponentProps<typeof Seo>;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<Seo {...Astro.props} />
</head>
<body>
<slot />
</body>
</html>
A page then passes its own values:
---
// src/pages/index.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout
title="Example Blog"
description="Articles about building fast websites with Astro."
>
<h1>Example Blog</h1>
</BaseLayout>
The reusable layouts guide covers layouts in more depth, including nesting a blog layout inside a base layout.
Pulling Meta Data from Content Collections
For a blog, the title, description and image usually live in each post's frontmatter. Define them in your collection schema so they're validated at build time:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string().max(160),
pubDate: z.coerce.date(),
image: z.string().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
The .max(160) rule stops overly long descriptions at build time. In Astro 6 and later, z is imported from astro/zod, and collections live in src/content.config.ts with a loader. See collection schemas with Zod for more validation ideas.
Then pass the post's data to the layout from the dynamic route:
---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout
title={post.data.title}
description={post.data.description}
image={post.data.image}
type="article"
publishedTime={post.data.pubDate}
>
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
</BaseLayout>
Building this in an Astro 7.4.2 project produced the following head for a post:
<title>Moving my blog to Astro | Example Blog</title>
<meta name="description" content="Why I moved from a single-page app to Astro, and what changed for performance and SEO.">
<link rel="canonical" href="https://example.com/blog/first-post/">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Example Blog">
<meta property="og:title" content="Moving my blog to Astro">
<meta property="og:description" content="Why I moved from a single-page app to Astro, and what changed for performance and SEO.">
<meta property="og:url" content="https://example.com/blog/first-post/">
<meta property="og:image" content="https://example.com/images/first-post.png">
<meta property="article:published_time" content="2026-10-01T00:00:00.000Z">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Moving my blog to Astro">
<meta name="twitter:description" content="Why I moved from a single-page app to Astro, and what changed for performance and SEO.">
<meta name="twitter:image" content="https://example.com/images/first-post.png">
Canonical URLs and Trailing Slashes
Notice the trailing slash in the canonical URL above. With the default settings, Astro builds each page as blog/first-post/index.html, and Astro.url.pathname during a static build ends in a slash. That's fine, as long as it matches the URL your host actually serves and the URLs in your sitemap and internal links.
If you prefer URLs without a trailing slash, make it explicit:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
trailingSlash: "never",
});
With trailingSlash: "never", the same page's canonical became https://example.com/blog/first-post. Whichever style you choose, keep it consistent. Mismatches between the canonical, the sitemap and redirects are one of the most common technical SEO problems.
Some other canonical rules of thumb:
- Drop query strings: building from
Astro.url.pathnamerather thanAstro.urlignores tracking parameters and filters. - Paginated pages: each page of a listing, such as
/blog/2, should usually be its own canonical rather than pointing to page one. - Syndicated content: if a post is republished elsewhere, the copy should point its canonical at your original.
Keeping Pages Out of the Index
Search results pages, thank-you pages and drafts usually shouldn't appear in search engines. The noindex prop on the component handles that:
---
// src/pages/search.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout title="Search" description="Search the blog." noindex>
<h1>Search</h1>
</BaseLayout>
This renders <meta name="robots" content="noindex, follow">, which asks search engines not to index the page but still follow its links. Don't also block these pages in robots.txt: a crawler that can't fetch the page never sees the noindex.
Generating robots.txt
You can serve robots.txt from a static file in public/, or generate it with an endpoint so the sitemap URL always matches site:
// src/pages/robots.txt.ts
import type { APIRoute } from "astro";
export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL("sitemap-index.xml", site);
return new Response(
`User-agent: *\nAllow: /\n\nSitemap: ${sitemapURL.href}\n`,
{ headers: { "Content-Type": "text/plain; charset=utf-8" } },
);
};
The build writes this to dist/robots.txt:
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap-index.xml
The sitemap-index.xml file is generated by the @astrojs/sitemap integration, covered in generating a sitemap in Astro.
Multilingual Pages
For sites in several languages, each page should set lang on <html> and list its translations with hreflang links:
<link rel="alternate" hreflang="en" href="https://example.com/en/about/" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/about/" />
Astro's i18n routing helpers, such as getAbsoluteLocaleUrl() from astro:i18n, can build these URLs for you. The i18n routing guide explains the setup.
Going Further: Social Images and Structured Data
Two additions make the biggest difference after the basics:
- Open Graph images: a 1200 by 630 image per page makes shared links far more visible. You can generate them automatically at build time, as shown in adding Open Graph images in Astro.
- Structured data: JSON-LD tells search engines what a page is about, such as an article with an author and date, and can make it eligible for rich results. See adding JSON-LD in Astro.
Community packages such as astro-seo wrap the same tags in a ready-made component. They're convenient, but the hand-written component above is short, fully typed and has no dependency to keep up to date.
Checking Your Tags
After building, check the output rather than trusting the source:
- Inspect the built HTML: open a file in
dist/or view the page source in the browser, and check every tag has the right value. - Test link previews: paste a deployed URL into the sharing debuggers provided by social platforms, or into a chat app, to see the card.
- Use Google Search Console: the URL Inspection tool shows the canonical Google selected and whether the page can be indexed.
- Check for duplicates: crawl the site with a tool such as Screaming Frog to find missing or repeated titles and descriptions.
FAQ: SEO Meta Tags in Astro
No. Astro renders pages to HTML on the server, so a simple component that outputs title, description, canonical and Open Graph tags in the head is enough. Plugins are optional conveniences.
Astro.site is only set when you add the site option, with your full production URL, to astro.config.mjs. Without it, absolute URLs for canonical links and social images can't be built.
Give your layout or Seo component a title prop and pass a value from each page, or from each content collection entry's frontmatter. The component then renders it inside the title element in the head.
It should match the URL your site actually serves. Astro's default directory build output gives pathnames ending in a slash; set trailingSlash to never or always to make your choice explicit and keep canonicals, sitemap and links consistent.
Render a robots meta tag with the value noindex in that page's head, for example through a noindex prop on your Seo component. Don't block the page in robots.txt as well, or crawlers won't see the tag.
Yes. Social platforms fetch og:image from their own servers, so the value must be a full URL including the domain. Build it with new URL(path, Astro.site).
Conclusion
SEO meta tags in Astro come down to one well-made component. Set site in your config, build a typed Seo component that renders the title, description, canonical, Open Graph and Twitter tags, place it in your base layout's <head>, and feed it from page props or validated content collection frontmatter. Because Astro sends finished HTML, those tags are exactly what crawlers and social platforms read.
From there, decide on a trailing slash policy and keep canonicals consistent with it, use noindex for pages that shouldn't be found, and generate robots.txt and a sitemap from the same site value. Add social images and structured data on top, check the built output, and your Astro site will have a solid technical SEO foundation.


