· 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.

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:

FeatureDescription
Global MetadataCentralized management of meta tags for the entire site.
Open Graph & TwitterConfiguration of social cards for sharing content.
JSON-LDImplementation of structured data for Google.
Type SafetyUse of TypeScript.

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:

Tool/StepDownload Link
Node.js (v18.17.1+)Download Node.js
Astro CLIInstall Astro
VS CodeDownload VS Code

Dependencies

We will work with the following libraries:

DependencyDescription
@astrolib/seoA library to handle SEO, Open Graph, and Twitter meta tags declaratively.
schema-dtsProvides TypeScript definitions for all Schema.org schemas, allowing you to create JSON-LD.
lodash.mergeUsed to combine global metadata objects with page-specific ones.
package.json
{
  "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 post

Typed 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:

config.yaml
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_image

Metadata

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.

src/components/common/Metadata.astro
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: Combines astrowind:config with 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:, and twitter: 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.

src/ld-json/utils.ts
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 from schema-dts, including the @context field.
  • headline and description: 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.

src/ld-json/utils.ts
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:

src/pages/[...blog]/index.astro
---
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

  1. Layout: Receives metadata and structured data.
  2. Metadata.astro: Processes and renders traditional meta tags.
  3. JSON-LD: Improves visibility with rich snippets.
  4. TypeScript: Ensures the entire data chain is valid and consistent.

Sources Consulted

  • Astro
  • SEO
  • TypeScript
Share: