· Jose Antonio López · 6 min read
SEO in Astro - Guide to Configuring Metadata, Open Graph, and JSON-LD
Learn how to implement a technical SEO strategy in Astro. Configure dynamic metadata, Open Graph tags, Twitter Cards, structured data with JSON-LD, and type safety with schema-dts to improve indexing and visibility on Google.

Objective
Provide a real technical guide on how to configure SEO in a web project with Astro, focused on a blog. Technically, the following is explained:
Why am I writing this article?
Most sites add <title>, <meta name="description">, and little else. However, in real applications, you need to structure pages with rich data.
In this article, I detail how to build a system that allows reusing global metadata, overwriting it on pages, and generating structured data (JSON-LD) without cluttering the component logic.
Prerequisites
To follow this article, you need to have installed:
Dependencies
We will work with the following libraries:
{
"dependencies": {
"@astrolib/seo": "1.0.0-beta.8",
"schema-dts": "^1.1.2",
"lodash.merge": "^4.6.2"
}
}Design Decisions
Project Structure
Below you can see the files for the SEO and structured data layer:
src
├── config.yaml // Global site configuration
├── components
│ └── common
│ ├── CommonMeta.astro // i18n and base tags
│ └── Metadata.astro // Logic with @astrolib/seo
├── ld-json
│ ├── pages
│ │ └── blog.ts // Blog page schemas
│ ├── types.ts // Type definitions
│ └── utils.ts // JSON-LD generators
├── layouts
│ ├── Layout.astro // Generic layout
│ └── BlogEntryLayout.astro // Specialized layout for posts
└── pages
└── [...blog]
└── index.astro // Final implementation of the postTyped Structured Data
Instead of writing plain JSON, schema-dts is used to ensure no errors are made in property names (for example, writing headline instead of name where it doesn’t belong).
Site Configuration Loaded with Astrowind Integration
To avoid hardcoding values, a configuration file called src/config.yaml is used. Through an AstroWind integration, values are exposed in a virtual module astrowind:config, and the metadata component accesses the global configuration.
This is the easiest way I’ve found for my website since it loads the configuration and the data comes typed in one place within the SITE and METADATA variables.
If you need something more customized and want a reference, you can visit the official AstroWind repository in the “integration” section.
Global SEO
Configuration
The website configuration is stored in the src/config.yaml file:
site:
name: josealopez.dev
site: https://josealopez.dev
metadata:
title:
default: Web development blog
template: '%s | Jose López'
description: Web development blog, tutorials, guides, and resources on backend and frontend.
robots:
index: true
follow: true
openGraph:
site_name: josealopez.dev
images:
- url: '~/assets/images/whatever.png'
width: 1200
height: 628
type: website
twitter:
handle: '@josealopez_dev'
site: '@josealopez_dev'
cardType: summary_large_imageMetadata
The central piece is the Metadata.astro component. The component receives the SEO properties and injects them into the <head>. This component originally comes from AstroWind and has been adapted to my needs.
import merge from 'lodash.merge';
import { AstroSeo } from '@astrolib/seo';
import { SITE, METADATA } from 'astrowind:config';
import type { MetaData } from '~/types';
export interface Props extends MetaData {}
const {
title,
canonical,
description,
openGraph = {},
} = Astro.props;
const seoProps = merge(
{
titleTemplate: METADATA?.title?.template || '%s',
description: METADATA?.description,
canonical: canonical,
openGraph: {
site_name: SITE?.name,
type: 'website',
},
},
{
title: title,
description: description,
openGraph: { url: canonical, ...openGraph },
}
);
---
<AstroSeo {...{ ...seoProps, openGraph: await adaptOpenGraphImages(seoProps?.openGraph, Astro.site) }} />Code Explanation
merge: Combinesastrowind:configwith the component props. Values on the right overwrite those on the left.titleTemplate: Allows all pages to have a consistent format (e.g., “My Post | Jose Lopez”) without having to write the site name every time.AstroSeo: Component from the library that handles<title>,<meta name="description">,og:, andtwitter:tags.
Tip
Using a titleTemplate with %s is a good SEO practice to maintain brand consistency in Google search results.
SEO for Structured Data
For blog posts, I needed a schema to generate JSON-LD. The goal is for search engines to understand the site’s structure and content without needing to read all the HTML. It’s better to make things easy for indexing bots before they leave without indexing the important parts.
JSON-LD Generator
A utility is created that transforms a post object into a valid BlogPosting object within schema.org.
import type { BlogPosting, WithContext } from 'schema-dts';
import type { Post } from '~/types';
export function createArticleJsonLd(post: Post): WithContext<BlogPosting> {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
datePublished: post.publishDate.toISOString(),
dateModified: post.updateDate?.toISOString() || post.publishDate.toISOString(),
image: post.image,
author: {
'@type': 'Person',
name: 'Jose Antonio López',
url: 'https://josealopez.dev'
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': post.metadata?.canonical
}
};
}Code Explanation
WithContext<BlogPosting>: Provides the necessary typing fromschema-dts, including the@contextfield.headlineanddescription: These are critical fields for Google to understand the article summary.datePublished/dateModified: Help Google know if the content is fresh or has been recently updated.
Post type
For reference, you can see the variables within the Post type.
export interface Post {
id: string;
publishDate: Date;
updateDate?: Date;
title: string;
excerpt?: string;
image?: string;
metadata?: MetaData;
}Final Integration on the Page
Finally, we put everything together on the blog page:
---
import type { InferGetStaticPropsType, GetStaticPaths } from 'astro';
import merge from 'lodash.merge';
import type { ImageMetadata } from 'astro';
import { createArticleJsonLd } from '~/ld-json/utils';
export const getStaticPaths = (async () => {
return await getStaticPathsBlogPost();
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post } = Astro.props as Props;
const postPath = getBlogPermalink(post.permalink, language);
const url = getCanonical(getPermalink(postPath, 'post'));
const image = (await findImage(post.image)) as ImageMetadata | string | undefined;
const metadata = merge(
{
title: post.title,
description: post.excerpt,
robots: {
index: blogPostRobots?.index,
follow: blogPostRobots?.follow,
},
openGraph: {
type: 'article',
...(image
? { images: [{ url: image, width: (image as ImageMetadata)?.width, height: (image as ImageMetadata)?.height }] }
: {}),
},
},
{ ...(post?.metadata ? { ...post.metadata, canonical: post.metadata?.canonical || url } : {}) }
) as MetaData;
const structuredData = createArticleJsonLd(post, language);
---
<PageLayout metadata={metadata} structuredData={structuredData}>
<SinglePost post={{ ...post, image: image }} url={url}>
{post.Content ? <post.Content /> : <Fragment set:html={post.content || ''} />}
</SinglePost>
</PageLayout>Code Explanation
Astro currently has the post file loaded to build it via SSG (Server Side Generation). The goal is to use the data you need at that moment. This is the right time to build all the SEO content and pass it to another Layout with the document structure.
metadata: Passed to the Layout you are using.structuredData: Passed to the Layout you are using.
Important
Never forget that JSON-LD must be injected as a valid JSON string within a script tag. Astro allows this to be done safely with the set:html directive.
SEO Architecture Summary
- Layout: Receives metadata and structured data.
- Metadata.astro: Processes and renders traditional meta tags.
- JSON-LD: Improves visibility with rich snippets.
- TypeScript: Ensures the entire data chain is valid and consistent.
Sources Consulted
- Astro
- SEO
- TypeScript