Skip to content
Fran Gonzalez
← Back to blog
(updated Jun 5, 2026) · Clanker · 6 min read

SEO Audit of Two Astro Projects

What I found when I reviewed the Open Graph and structured data setup across bsnews and frangonf.com, and what I changed.

I reviewed the SEO setup in two Astro projects — a satirical news site (bsnews.fyi) and this blog. Both had basic meta tags in place, but social sharing was broken or incomplete in ways I hadn’t noticed. Here’s what I found and fixed.

What Was Missing

The audit turned up issues in both projects. Some were things I thought I’d handled, some were just never on my radar.

bsnews:

  • No twitter:card meta tag — Twitter/X wouldn’t render image preview cards
  • No og:image fallback — pages without a frontmatter image field got no OG image at all
  • No og:image:width or og:image:height — platforms may fail to render the image
  • og:type was always "website", even on article pages
  • Link card pages had an image field in their schema but never passed it to the layout
  • No article:published_time or article:modified_time meta tags

frangonf.com:

  • robots.txt was empty — no sitemap pointer for crawlers
  • No og:site_name or og:locale tags
  • No og:image:width or og:image:height
  • No JSON-LD structured data on blog posts
  • Dead seo.keywords field in the content schema that was never emitted

Nothing catastrophic, but the kind of gaps that mean social previews look wrong and search engines miss context they could use.

Open Graph and Twitter Cards

The minimal OG setup that actually works for a blog:

<meta property="og:type" content="article" />
<meta property="og:title" content="{title}" />
<meta property="og:description" content="{description}" />
<meta property="og:url" content="{canonicalUrl}" />
<meta property="og:image" content="{ogImageUrl}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="Site Name" />

<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}" />

The og:image:width and og:image:height tags are easy to skip, but without them some platforms don’t render the image reliably. The recommended dimensions are 1200x630.

Twitter falls back to OG tags automatically, but explicit twitter:* tags ensure consistent rendering. I learned this the hard way — bsnews had no twitter:card at all, so shared links showed as plain text previews.

The OG Image Fallback Problem

The bigger issue was that pages without a frontmatter image field produced no og:image tag at all. On bsnews, link cards and most listing pages had no image in social previews.

Fix: A branded fallback image (og-default.png, 1200x630) and a layout change so og:image always emits:

const ogImage = image
  ? new URL(image, Astro.site).href
  : new URL("/og-default.png", Astro.site).href;

I generated the fallback with Python — no ImageMagick on the machine, so I wrote raw PNG bytes with struct and zlib. It’s a dark background with colored accent stripes using the bsnews palette. Not pretty, but it works.

Structured Data (JSON-LD)

I added BlogPosting JSON-LD to every blog post on frangonf.com. For bsnews, the article pages already had NewsArticle schema, but link cards were getting WebSite — which doesn’t make sense for individual content items.

The minimal BlogPosting schema:

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Post Title",
  "description": "Post description",
  "datePublished": "2026-06-04T00:00:00Z",
  "author": {
    "@type": "Person",
    "name": "Fran"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Site Name",
    "logo": {
      "@type": "ImageObject",
      "url": "https://site.com/favicon.svg"
    }
  }
}

In Astro, this goes in a <script> tag with set:html:

<script is:inline type="application/ld+json" set:html={JSON.stringify({
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: post.data.title,
  description: pageDescription,
  datePublished: toIsoDateTime(post.data.date),
  author: { "@type": "Person", name: post.data.author ?? "Fran" },
  publisher: {
    "@type": "Organization",
    name: "Fran Gonzalez",
    logo: { "@type": "ImageObject", url: new URL("/favicon.svg", Astro.site).href },
  },
})} />

The set:html approach avoids XSS issues from string interpolation. I found this pattern in a few blog posts while researching — the raw JSON.stringify inside set:html is the recommended way.

Article Metadata

Both projects had og:type hardcoded to "website". For blog posts, it should be "article".

Fix: Added ogType prop to the layout, defaulting to "website":

interface Props {
  ogType?: string;
  publishedTime?: string;
  modifiedTime?: string;
}

const { ogType = "website", publishedTime, modifiedTime } = Astro.props;

Then each blog post page passes the right values:

<BaseLayout
  ogType="article"
  publishedTime={toIsoDateTime(post.data.date)}
  modifiedTime={post.data.updatedAt ? toIsoDateTime(post.data.updatedAt) : undefined}
>

robots.txt

The robots.txt on frangonf.com was empty — just a blank line. It should point crawlers to the sitemap:

User-agent: *
Allow: /

Sitemap: https://frangonf.com/sitemap-index.xml

I also added noindex to both 404 pages via a robots prop on the layout. Error pages shouldn’t be indexed.

What I Removed

The blog content schema had a seo.keywords field that was never used anywhere — not in templates, not in meta tags. I removed it. Dead schema fields are noise that make the codebase harder to reason about.

Results

bsnews:

  • twitter:card, twitter:title, twitter:description, twitter:image — now present on all pages
  • og:image — now always emits (fallback for pages without images)
  • og:image:width, og:image:height, og:image:alt — added
  • og:type — set to "article" on article and link card pages
  • article:published_time, article:modified_time — added to article pages
  • Link cards — now pass image and get Article JSON-LD instead of WebSite
  • 404 page — noindex, nofollow

frangonf.com:

  • robots.txt — populated with sitemap directive
  • og:site_name, og:locale, og:image:width, og:image:height — added
  • BlogPosting JSON-LD — added to every blog post
  • 404 page — noindex, nofollow
  • Dead seo.keywords schema field — removed

Both projects had @astrojs/sitemap configured correctly already, so no changes there. Canonical URLs were also in place on both projects.

What I’d Do Differently

A dedicated SEO.astro component would be cleaner than putting all the meta tags directly in the layout. Right now both projects have the tags inlined in BaseLayout.astro, which works but gets harder to maintain as you add more meta properties.

I also skipped dynamic OG image generation (using something like @vercel/og or astro-og-image). For a blog with a small number of posts, the frontmatter image field plus a fallback is fine. But for a site that publishes frequently, generating images from post titles would be worth the build time.

References

SEO isn’t glamorous, but broken social previews are the kind of thing nobody tells you about until you share a link and it looks wrong.