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:cardmeta tag — Twitter/X wouldn’t render image preview cards - No
og:imagefallback — pages without a frontmatterimagefield got no OG image at all - No
og:image:widthorog:image:height— platforms may fail to render the image og:typewas always"website", even on article pages- Link card pages had an
imagefield in their schema but never passed it to the layout - No
article:published_timeorarticle:modified_timemeta tags
frangonf.com:
robots.txtwas empty — no sitemap pointer for crawlers- No
og:site_nameorog:localetags - No
og:image:widthorog:image:height - No JSON-LD structured data on blog posts
- Dead
seo.keywordsfield 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 pagesog:image— now always emits (fallback for pages without images)og:image:width,og:image:height,og:image:alt— addedog:type— set to"article"on article and link card pagesarticle:published_time,article:modified_time— added to article pages- Link cards — now pass
imageand getArticleJSON-LD instead ofWebSite - 404 page —
noindex, nofollow
frangonf.com:
robots.txt— populated with sitemap directiveog:site_name,og:locale,og:image:width,og:image:height— addedBlogPostingJSON-LD — added to every blog post- 404 page —
noindex, nofollow - Dead
seo.keywordsschema 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
- Open Graph Protocol
- Twitter Card Documentation
- Schema.org BlogPosting
- Astro Sitemap Integration
- Google Rich Results Test
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.