Astro Content Collections OG Tags: Type-Safe Social Previews

How to generate og:title, og:image, og:description from Astro Content Collections. Covers schema validation, dynamic OG images, and type-safe frontmatter for social cards.

Why Content Collections are perfect for OG tags

Astro Content Collections validate your content at build time with Zod schemas. This means you can enforce that every blog post, doc page, or product page has the required OG fields — title, description, and image — before the site even builds.

No more missing OG tags slipping into production because a content author forgot to add a description. If the field is required in your schema, the build fails without it.

Define your content schema with OG fields

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string().max(60, 'OG title should be under 60 chars'),
    description: z.string().max(160, 'OG description should be under 160 chars'),
    ogImage: z.string().optional(), // Custom OG image path
    publishDate: z.coerce.date(),
    author: z.string().default('Team'),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

With this schema, every blog post in src/content/blog/ must have a title under 60 characters and a description under 160 characters — enforced at build time.

Use collection data in OG meta tags

---
// src/pages/blog/[...slug].astro
import { getCollection, type CollectionEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

type Props = { post: CollectionEntry<'blog'> };
const { post } = Astro.props;
const { title, description, ogImage } = post.data;

const ogImageUrl = ogImage
  ? new URL(ogImage, Astro.site).href
  : new URL(`/og/${post.slug}.png`, Astro.site).href;
---

<BaseLayout>
  <head slot="head">
    <title>{title}</title>
    <meta name="description" content={description} />
    
    <!-- Open Graph -->
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={ogImageUrl} />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta property="og:type" content="article" />
    <meta property="og:url" content={Astro.url.href} />
    
    <!-- Twitter Card -->
    <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={ogImageUrl} />
  </head>

  <article>
    <h1>{title}</h1>
    <Content />
  </article>
</BaseLayout>

Generate dynamic OG images from collections

Create an API endpoint that generates OG images from collection data at build time:

// src/pages/og/[...slug].png.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync } from 'fs';

const font = readFileSync('./public/fonts/Inter-Bold.ttf');

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { title: post.data.title, description: post.data.description },
  }));
}

export const GET: APIRoute = async ({ props }) => {
  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          display: 'flex',
          flexDirection: 'column',
          background: '#0a0a0a',
          width: '100%',
          height: '100%',
          padding: '60px',
          justifyContent: 'center',
        },
        children: [
          {
            type: 'div',
            props: {
              style: { fontSize: 56, fontWeight: 700, color: '#fff', lineHeight: 1.2 },
              children: props.title,
            },
          },
          {
            type: 'div',
            props: {
              style: { fontSize: 24, color: '#a1a1aa', marginTop: 20 },
              children: props.description,
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Inter', data: font, weight: 700 }],
    }
  );

  const png = new Resvg(svg).render().asPng();
  return new Response(png, {
    headers: { 'Content-Type': 'image/png' },
  });
};

This generates a PNG for each blog post at build time — no runtime rendering needed. The images are served as static files, making them blazing fast for social crawlers.

Validation gotchas

  • Content Collections validate at build time — if a field is wrong, the build fails with a clear Zod error
  • Use .optional() wisely — mark ogImage optional but title/description required
  • Set .max() limits to prevent truncation on social platforms
  • Always generate absolute URLs for og:image using Astro.site

Verify your Astro site's OG tags

After deploying your Astro Content Collections site, test your OG tags across all platforms with OGFixer.

Check your Astro OG tags →