Why This Matters: The Messaging-First Share Economy
The world runs on share buttons. When someone taps "Share" on mobile and sends a link via iMessage, WhatsApp, LinkedIn DM, Slack, or Discord, the link preview that appears IS your brand's first impression.
This is now more important than traditional SEO for many businesses:
iMessage/WhatsApp are how people share interesting content with friends, family, and colleagues
LinkedIn is where B2B relationships form and professional content spreads
Slack/Discord are where teams discover tools and resources
Email clients increasingly render rich link previews
A broken or missing preview means your content looks unprofessional, untrustworthy, or simply gets ignored. A compelling preview drives clicks.
This blueprint ensures every page on every site produces rich, accurate, platform-optimized previews.
Objectives
Implement an elegant, understandable, usable system for codifying and improvising Open Graph metadata at a per-page or per-collection or per-shareable-content level (any level the developers or marketers need).
Optimize for messaging apps first (WhatsApp, iMessage, LinkedIn), not just traditional social.
Centralize defaults while allowing clean per-page overrides.
Keep metadata rendering consistent in one place (layout), not scattered.
Support dynamic routes, content collections, and multi-brand needs.
Integrate structured data (JSON-LD) alongside Open Graph.
Enable optional dynamic OG image generation without complicating most pages.
Guiding Principles
One source of truth for defaults and types
Small, composable helpers that return ready-to-render meta tags
Pages provide only the minimum context (title/description/image/url); everything else is inferred
Layout owns actual
<meta>and canonical rendering for consistencyAlways use absolute URLs — messaging apps require them; relative paths break previews
Respect character limits — platforms truncate aggressively
Override Hierarchy
The system should be approachable at every level of the organization:
| Level | Who Uses It | What They Do |
| Site defaults | Developer (once) | Set SITE_SEO config and forget — 80% of pages are covered automatically |
| Collection defaults | Developer | Define defaults per content type (blog posts get article type, team pages get profile type) |
| Page overrides | Developer or Marketer | High-value landing pages get custom titles, descriptions, images via props or frontmatter |
| Frontmatter fields | Marketer or Content Author | Specify shareImage, description directly in markdown — no code changes needed |
The goal: A marketer should be able to control share previews by editing frontmatter. A developer should be able to set sensible defaults and walk away. Neither should need to understand the other's domain.
Platform-Specific Considerations
Different platforms render previews differently. Design for the lowest common denominator, then enhance.
Primary concern: Aggressive caching. WhatsApp caches previews for extended periods.
Image: Uses
og:image. Displays at roughly 300x157 (crops to ~1.91:1 ratio)Title: Uses
og:title. Truncates around 65 charactersDescription: Uses
og:description. Truncates around 100 charactersCache busting: Nearly impossible. Change the URL or wait. Adding query params like
?v=2can help force re-fetch.Requirement:
og:imageMUST be an absolute URL with HTTPS
iMessage (iOS/macOS)
Image: Uses
og:image. Large preview on iOS 17+Title: Uses
og:titleDescription: Uses
og:descriptionBehavior: Generally respects meta tags well; caches moderately
Fallback: Will attempt to generate preview from page content if OG tags missing
Primary use case: B2B content, professional sharing, company pages
Image: Uses
og:image. Displays at 1200x627 (crops to 1.91:1). Images smaller than 200x200 may not display.Title: Uses
og:title. Truncates around 70 charactersDescription: Uses
og:description. Truncates around 100 characters in feedSpecial tags: Recognizes
article:author,article:published_timeCache: Caches aggressively. Use Post Inspector to force refresh.
Slack
Image: Uses
og:image. Unfurls with large previewTitle: Uses
og:titleDescription: Uses
og:descriptionSpecial: Recognizes
twitter:label1/twitter:data1for additional metadata displayFallback: Falls back to page scraping if OG missing
Discord
Image: Uses
og:image. Embeds with previewTitle: Uses
og:titleDescription: Uses
og:descriptionColor: Recognizes
theme-colormeta tag for embed accent colorSpecial: Shows site name from
og:site_name
Twitter/X
Card types:
summary,summary_large_image,player,appImage: Uses
twitter:image(falls back toog:image)Title: Uses
twitter:title(falls back toog:title). Max 70 charsDescription: Uses
twitter:description(falls back toog:description). Max 200 charsRequired:
twitter:cardto specify card type
Image: Uses
og:image. Recommended 1200x630. Min 200x200, ideal 600x315+Title: Uses
og:titleDescription: Uses
og:descriptionSpecial: Full OG protocol support including arrays and structured properties
Character Limits Reference
Design content to look good when truncated:
| Property | Safe Length | Platform Notes |
og:title | 60 chars | LinkedIn/Twitter truncate ~70, but 60 is safe everywhere |
og:description | 155 chars | WhatsApp ~100, LinkedIn ~100 in feed, Twitter ~200 |
og:site_name | 30 chars | Usually displayed in full |
Recommendation: Helper functions should warn or truncate when limits exceeded.
Recommended Structure
Project-level defaults and helpers
src/
├── config/
│ └── seo.ts # Site defaults, types, character limits
├── utils/
│ ├── og.ts # OG/Twitter meta tag builder
│ └── structured-data.ts # JSON-LD schema builders
└── layouts/
└── BaseLayout.astro # Renders meta, canonical, JSON-LD Monorepo shared package (optional, as sites grow)
packages/seo/
├── types.ts # Shared interfaces
├── defaults.ts # Default builder factory
├── og.ts # OG helper
├── structured-data.ts # JSON-LD helpers
└── index.ts # Barrel export Sites import from @knots/seo (following the copy-pattern workflow).
Site Defaults (Config)
Define one config object to drive defaults across pages.
// src/config/seo.ts
export interface SiteSEO {
siteName: string;
siteUrl: string; // REQUIRED: absolute URL for production (e.g., 'https://example.com')
twitterHandle?: string;
linkedInCompany?: string; // Company page URL for attribution
defaultTitle: string;
defaultDescription: string;
defaultImage: string; // MUST be absolute URL or path under /public
defaultImageAlt: string;
themeColor?: string; // For Discord embeds, PWA, etc.
locale?: string; // e.g., 'en_US'
}
export interface ShareMetaInput {
title?: string;
description?: string;
image?: string;
imageAlt?: string;
url?: string;
type?: 'website' | 'article' | 'profile' | 'product' | string;
// Article-specific (for blog posts, news)
publishedTime?: string; // ISO 8601
modifiedTime?: string; // ISO 8601
author?: string;
section?: string; // e.g., 'Technology', 'Business'
tags?: string[];
}
// Character limits for safety
export const CHAR_LIMITS = {
title: 60,
description: 155,
siteName: 30,
} as const;
export const SITE_SEO: SiteSEO = {
siteName: 'Your Site Name',
siteUrl: 'https://example.com', // Set from env in production
defaultTitle: 'Your Site Name',
defaultDescription: 'Your compelling site description under 155 characters.',
defaultImage: '/og-default.jpg', // 1200x630, under /public
defaultImageAlt: 'Your Site Name logo and tagline',
twitterHandle: '@yourhandle',
themeColor: '#1a1a2e',
locale: 'en_US',
}; Helper API (Meta Composition)
Keep helpers small, predictable, and defensive about character limits.
// src/utils/og.ts
import { SITE_SEO, CHAR_LIMITS } from '../config/seo';
import type { ShareMetaInput } from '../config/seo';
interface MetaTag {
name?: string;
property?: string;
content: string;
}
/**
* Truncates string to limit, adding ellipsis if needed
*/
function truncate(str: string, limit: number): string {
if (str.length <= limit) return str;
return str.slice(0, limit - 1).trim() + '…';
}
/**
* Ensures URL is absolute. Prepends siteUrl if relative.
*/
function ensureAbsoluteUrl(url: string, siteUrl: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Handle leading slash
const path = url.startsWith('/') ? url : `/${url}`;
return `${siteUrl.replace(/\/$/, '')}${path}`;
}
/**
* Builds Open Graph and Twitter meta tags from input.
* Falls back to site defaults for any missing values.
*/
export function buildOgMeta(input: ShareMetaInput = {}): MetaTag[] {
const siteUrl = SITE_SEO.siteUrl;
const title = truncate(input.title ?? SITE_SEO.defaultTitle, CHAR_LIMITS.title);
const description = truncate(input.description ?? SITE_SEO.defaultDescription, CHAR_LIMITS.description);
const image = ensureAbsoluteUrl(input.image ?? SITE_SEO.defaultImage, siteUrl);
const imageAlt = input.imageAlt ?? SITE_SEO.defaultImageAlt;
const url = input.url ? ensureAbsoluteUrl(input.url, siteUrl) : undefined;
const type = input.type ?? 'website';
const meta: MetaTag[] = [
// Basic meta
{ name: 'description', content: description },
// Open Graph (primary - used by most platforms)
{ property: 'og:type', content: type },
{ property: 'og:site_name', content: SITE_SEO.siteName },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:image', content: image },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:image:alt', content: imageAlt },
];
// URL (required for proper canonical reference)
if (url) {
meta.push({ property: 'og:url', content: url });
}
// Locale
if (SITE_SEO.locale) {
meta.push({ property: 'og:locale', content: SITE_SEO.locale });
}
// Article-specific properties (for blog posts, news articles)
if (type === 'article') {
if (input.publishedTime) {
meta.push({ property: 'article:published_time', content: input.publishedTime });
}
if (input.modifiedTime) {
meta.push({ property: 'article:modified_time', content: input.modifiedTime });
}
if (input.author) {
meta.push({ property: 'article:author', content: input.author });
}
if (input.section) {
meta.push({ property: 'article:section', content: input.section });
}
if (input.tags?.length) {
input.tags.forEach(tag => {
meta.push({ property: 'article:tag', content: tag });
});
}
}
// Twitter Card (fallback to OG, but explicit is better)
meta.push({ name: 'twitter:card', content: 'summary_large_image' });
if (SITE_SEO.twitterHandle) {
meta.push({ name: 'twitter:site', content: SITE_SEO.twitterHandle });
}
meta.push({ name: 'twitter:title', content: title });
meta.push({ name: 'twitter:description', content: description });
meta.push({ name: 'twitter:image', content: image });
meta.push({ name: 'twitter:image:alt', content: imageAlt });
// Theme color (for Discord embeds, PWA, browser chrome)
if (SITE_SEO.themeColor) {
meta.push({ name: 'theme-color', content: SITE_SEO.themeColor });
}
return meta;
}
/**
* Builds canonical URL from pathname
*/
export function buildCanonical(pathname: string): string {
return ensureAbsoluteUrl(pathname, SITE_SEO.siteUrl);
} JSON-LD Structured Data
Why JSON-LD matters: While OG tags control social previews, JSON-LD/Schema.org structured data powers:
Google rich results (breadcrumbs, FAQ, articles, products)
Knowledge panels
Voice assistant answers
Future AI-powered search (GEO - Generative Engine Optimization)
Relationship to OG: They're complementary. OG tags = social sharing. JSON-LD = search engines and AI.
// src/utils/structured-data.ts
import { SITE_SEO } from '../config/seo';
interface WebSiteSchema {
'@context': 'https://schema.org';
'@type': 'WebSite';
name: string;
url: string;
description?: string;
publisher?: OrganizationSchema;
}
interface OrganizationSchema {
'@context'?: 'https://schema.org';
'@type': 'Organization';
name: string;
url: string;
logo?: string;
sameAs?: string[]; // Social profiles
}
interface ArticleSchema {
'@context': 'https://schema.org';
'@type': 'Article' | 'BlogPosting' | 'NewsArticle';
headline: string;
description?: string;
image?: string | string[];
datePublished?: string;
dateModified?: string;
author?: PersonSchema | OrganizationSchema;
publisher?: OrganizationSchema;
mainEntityOfPage?: string;
}
interface PersonSchema {
'@type': 'Person';
name: string;
url?: string;
}
interface BreadcrumbSchema {
'@context': 'https://schema.org';
'@type': 'BreadcrumbList';
itemListElement: {
'@type': 'ListItem';
position: number;
name: string;
item?: string;
}[];
}
/**
* Builds WebSite schema (use on homepage)
*/
export function buildWebSiteSchema(): WebSiteSchema {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_SEO.siteName,
url: SITE_SEO.siteUrl,
description: SITE_SEO.defaultDescription,
};
}
/**
* Builds Organization schema
*/
export function buildOrganizationSchema(options: {
logo?: string;
socialProfiles?: string[];
} = {}): OrganizationSchema {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: SITE_SEO.siteName,
url: SITE_SEO.siteUrl,
...(options.logo && { logo: options.logo }),
...(options.socialProfiles?.length && { sameAs: options.socialProfiles }),
};
}
/**
* Builds Article schema (for blog posts, news)
*/
export function buildArticleSchema(options: {
type?: 'Article' | 'BlogPosting' | 'NewsArticle';
headline: string;
description?: string;
image?: string;
datePublished?: string;
dateModified?: string;
authorName?: string;
authorUrl?: string;
url: string;
}): ArticleSchema {
const schema: ArticleSchema = {
'@context': 'https://schema.org',
'@type': options.type ?? 'Article',
headline: options.headline,
mainEntityOfPage: options.url,
};
if (options.description) schema.description = options.description;
if (options.image) schema.image = options.image;
if (options.datePublished) schema.datePublished = options.datePublished;
if (options.dateModified) schema.dateModified = options.dateModified;
if (options.authorName) {
schema.author = {
'@type': 'Person',
name: options.authorName,
...(options.authorUrl && { url: options.authorUrl }),
};
}
schema.publisher = {
'@type': 'Organization',
name: SITE_SEO.siteName,
url: SITE_SEO.siteUrl,
};
return schema;
}
/**
* Builds Breadcrumb schema
*/
export function buildBreadcrumbSchema(items: { name: string; url?: string }[]): BreadcrumbSchema {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
...(item.url && { item: item.url }),
})),
};
}
/**
* Serializes schema to JSON-LD script tag content
*/
export function serializeSchema(schema: object): string {
return JSON.stringify(schema);
} Layout Responsibilities
The base layout renders all meta tags, canonical, and JSON-LD consistently.
---
// src/layouts/BaseLayout.astro
import { buildOgMeta, buildCanonical } from '../utils/og';
import { buildWebSiteSchema, serializeSchema } from '../utils/structured-data';
import type { ShareMetaInput } from '../config/seo';
interface Props {
title: string;
meta?: ShareMetaInput;
jsonLd?: object | object[];
noIndex?: boolean;
}
const { title, meta = {}, jsonLd, noIndex = false } = Astro.props;
// Build OG meta tags
const ogMeta = buildOgMeta({
...meta,
url: meta.url ?? Astro.url.pathname,
});
// Build canonical URL
const canonical = buildCanonical(Astro.url.pathname);
// Prepare JSON-LD (array support for multiple schemas)
const schemas = jsonLd
? (Array.isArray(jsonLd) ? jsonLd : [jsonLd])
: [];
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<!-- Canonical -->
<link rel="canonical" href={canonical} />
<!-- Robots -->
{noIndex && <meta name="robots" content="noindex, nofollow" />}
<!-- OG and Twitter Meta -->
{ogMeta.map((m) => (
m.property
? <meta property={m.property} content={m.content} />
: <meta name={m.name} content={m.content} />
))}
<!-- JSON-LD Structured Data -->
{schemas.map((schema) => (
<script type="application/ld+json" set:html={serializeSchema(schema)} />
))}
<slot name="head" />
</head>
<body>
<slot />
</body>
</html> Image Fallback Chain
When determining which image to use for OG tags, follow this cascade:
Page-specific
shareImage— Explicitly set in frontmatter or page propsContent hero/banner image — The main visual of the content
Collection default — A default image for all posts in a collection (e.g., blog default)
Site default —
SITE_SEO.defaultImage
// Example: resolving image in a content page
const shareImage =
post.data.shareImage ?? // Explicit share image
post.data.heroImage ?? // Hero image as fallback
collectionDefaults.image ?? // Collection default
SITE_SEO.defaultImage; // Site default Tip: Create collection-specific defaults in your config:
// src/config/seo.ts
export const COLLECTION_DEFAULTS = {
blog: {
image: '/og-blog-default.jpg',
type: 'article' as const,
},
team: {
image: '/og-team-default.jpg',
type: 'profile' as const,
},
// ...
}; Asset Guidance
Dimensions
Primary: 1200x630 (1.91:1 ratio) — Works everywhere
Minimum: 600x315 — Below this, some platforms won't show image
Square fallback: 1200x1200 for platforms that crop to square (rare)
Format
Preferred: JPEG or WebP
Avoid: PNG with transparency (some platforms render black background)
File size: Keep under 5MB; ideally under 1MB for fast unfurling
Location
Store in
public/for stable, predictable URLsUse fingerprinted filenames for cache busting:
og-homepage-v2.jpg
Naming Convention
public/
├── og-default.jpg # Site-wide fallback
├── og-blog-default.jpg # Blog collection default
├── og-[page-name].jpg # Page-specific
└── og/
└── posts/
└── [slug].jpg # Per-post images (if generated) Page Usage Patterns
Static Page
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE_SEO } from '../config/seo';
import { buildWebSiteSchema } from '../utils/structured-data';
const title = 'About Us | Your Company';
const description = 'Learn about our mission, team, and values.';
---
<BaseLayout
title={title}
meta={{
title,
description,
image: '/og-about.jpg',
}}
jsonLd={buildWebSiteSchema()}
>
<!-- page content -->
</BaseLayout> Dynamic Route (Blog Post)
---
import { getEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { buildArticleSchema } from '../../utils/structured-data';
import { SITE_SEO, COLLECTION_DEFAULTS } from '../../config/seo';
const { slug } = Astro.params;
const post = await getEntry('blog', slug);
const title = post.data.title;
const description = post.data.description ?? post.data.excerpt;
const image = post.data.shareImage ?? post.data.heroImage ?? COLLECTION_DEFAULTS.blog.image;
const url = new URL(`/blog/${slug}`, SITE_SEO.siteUrl).toString();
const articleSchema = buildArticleSchema({
type: 'BlogPosting',
headline: title,
description,
image,
datePublished: post.data.publishedDate?.toISOString(),
dateModified: post.data.updatedDate?.toISOString(),
authorName: post.data.author,
url,
});
---
<BaseLayout
title={`${title} | ${SITE_SEO.siteName}`}
meta={{
title,
description,
image,
url,
type: 'article',
publishedTime: post.data.publishedDate?.toISOString(),
modifiedTime: post.data.updatedDate?.toISOString(),
author: post.data.author,
tags: post.data.tags,
}}
jsonLd={articleSchema}
>
<!-- post content -->
</BaseLayout> Content Collection Frontmatter Standard
Standardize frontmatter across collections for predictable metadata:
---
title: "Your Post Title"
description: "A compelling description under 155 characters."
publishedDate: 2025-01-15
updatedDate: 2025-01-20
author: "Author Name"
heroImage: "/images/posts/my-post-hero.jpg"
shareImage: "/og/posts/my-post.jpg" # Optional: explicit OG image
tags:
- technology
- web development
--- Absolute URLs and Canonical
Configuration
// astro.config.mjs
export default defineConfig({
site: 'https://your-domain.com',
}); Usage
// Always compute absolute URLs for OG tags
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const ogImage = new URL('/og-image.jpg', Astro.site).toString(); Critical: Messaging apps (especially WhatsApp) REQUIRE absolute HTTPS URLs. Relative paths will break previews.
Dynamic OG Image Generation
The Problem with Custom Images
Custom image overrides exist — marketers can always specify a shareImage in frontmatter to use a bespoke graphic. But this approach doesn't scale:
Creating custom OG images for every blog post, team member, or product page is time-consuming
Designers become bottlenecks
Quality becomes inconsistent
Many pages simply go without proper images
The GitHub Approach: Branded Templates + Dynamic Text
GitHub solved this elegantly. Every repository has a recognizable OG image:
Consistent branded layout — same border, colors, typography
Dynamic text — repo name, description, stats pulled in automatically
Instantly recognizable — you know it's a GitHub link before reading
This is the model to follow. Design 2-3 branded templates, then generate images dynamically by injecting page-specific text. The result:
Every page gets a proper OG image automatically
Brand consistency across hundreds of pages
Zero designer involvement for routine content
Custom overrides still available for hero campaigns
Implementation Approaches
| Approach | When to Use | Tools |
| Build-time | Finite, known content (blog, docs) | satori, @vercel/og, puppeteer |
| Runtime API | Dynamic/user-generated content | /api/og?title=... endpoint |
| Hybrid | Pre-generate key pages, runtime for long-tail | Combination |
Template Philosophy
Design templates that work with just a title. Everything else is optional enhancement:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ [LOGO] [SITE NAME] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ {{TITLE}} │ │
│ │ A Dynamic Title That Can Span Multiple Lines │ │
│ │ │ │
│ │ {{DESCRIPTION}} (optional, truncated) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ {{AUTHOR}} · {{DATE}} · {{CATEGORY}} [BRAND MARK] │
│ │
└─────────────────────────────────────────────────────────────────┘ Practical Template: Branded Card (Satori/Vercel OG)
// src/pages/api/og.ts
import { ImageResponse } from '@vercel/og';
import type { APIRoute } from 'astro';
// Load fonts (do this once, cache the result)
const interBold = fetch(
new URL('../../assets/fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
const interRegular = fetch(
new URL('../../assets/fonts/Inter-Regular.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
// Extract params with sensible defaults
const title = url.searchParams.get('title') ?? 'Untitled';
const description = url.searchParams.get('description') ?? '';
const author = url.searchParams.get('author') ?? '';
const date = url.searchParams.get('date') ?? '';
const category = url.searchParams.get('category') ?? '';
const type = url.searchParams.get('type') ?? 'default'; // 'default' | 'article' | 'profile'
// Truncate for safety
const truncatedTitle = title.length > 80 ? title.slice(0, 77) + '...' : title;
const truncatedDesc = description.length > 120 ? description.slice(0, 117) + '...' : description;
// Load fonts
const [boldFont, regularFont] = await Promise.all([interBold, interRegular]);
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: '60px',
backgroundColor: '#0f172a', // slate-900
color: '#f8fafc', // slate-50
fontFamily: 'Inter',
}}
>
{/* Header: Logo + Site Name */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '40px',
}}
>
{/* Replace with your logo - use base64 encoded SVG or hosted URL */}
<div
style={{
width: '48px',
height: '48px',
borderRadius: '8px',
backgroundColor: '#3b82f6', // blue-500
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
fontWeight: 700,
}}
>
L
</div>
<div
style={{
fontSize: '20px',
color: '#94a3b8', // slate-400
}}
>
yoursite.com
</div>
</div>
{/* Main Content Card */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '40px',
backgroundColor: '#1e293b', // slate-800
borderRadius: '16px',
border: '1px solid #334155', // slate-700
}}
>
{/* Category Badge (optional) */}
{category && (
<div
style={{
display: 'flex',
marginBottom: '20px',
}}
>
<span
style={{
padding: '6px 16px',
backgroundColor: '#3b82f6',
borderRadius: '9999px',
fontSize: '14px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{category}
</span>
</div>
)}
{/* Title */}
<h1
style={{
fontSize: truncatedTitle.length > 50 ? '42px' : '56px',
fontWeight: 700,
lineHeight: 1.2,
margin: 0,
marginBottom: description ? '20px' : '0',
}}
>
{truncatedTitle}
</h1>
{/* Description (optional) */}
{truncatedDesc && (
<p
style={{
fontSize: '24px',
color: '#94a3b8',
lineHeight: 1.4,
margin: 0,
}}
>
{truncatedDesc}
</p>
)}
</div>
{/* Footer: Author, Date, Brand Mark */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '30px',
}}
>
<div
style={{
display: 'flex',
gap: '16px',
fontSize: '18px',
color: '#64748b', // slate-500
}}
>
{author && <span>{author}</span>}
{author && date && <span>·</span>}
{date && <span>{date}</span>}
</div>
{/* Brand mark or tagline */}
<div
style={{
fontSize: '16px',
color: '#475569', // slate-600
}}
>
Your Brand Tagline
</div>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{ name: 'Inter', data: boldFont, weight: 700 },
{ name: 'Inter', data: regularFont, weight: 400 },
],
}
);
}; Using the Template in Pages
// In your buildOgMeta helper or page
const ogImageUrl = new URL('/api/og', SITE_SEO.siteUrl);
ogImageUrl.searchParams.set('title', post.data.title);
ogImageUrl.searchParams.set('description', post.data.description ?? '');
ogImageUrl.searchParams.set('author', post.data.author ?? '');
ogImageUrl.searchParams.set('date', formatDate(post.data.publishedDate));
ogImageUrl.searchParams.set('category', post.data.category ?? '');
// Use ogImageUrl.toString() as the og:image value Template Variants
Create multiple templates for different content types:
| Template | Use Case | Key Elements |
| Default | Homepage, landing pages | Logo, tagline, brand colors |
| Article | Blog posts, news | Title, author, date, category badge |
| Profile | Team pages, author pages | Photo placeholder, name, role |
| Product | Product pages | Product name, price, key feature |
Font Loading Best Practices
// Option 1: Bundle fonts (recommended for Vercel/Edge)
const font = fetch(new URL('./fonts/Inter-Bold.ttf', import.meta.url))
.then((res) => res.arrayBuffer());
// Option 2: Use system fonts (no loading required)
// 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial'
// Option 3: Google Fonts via URL (slower, may have CORS issues)
const font = fetch('https://fonts.gstatic.com/s/inter/v13/...')
.then((res) => res.arrayBuffer()); Dependencies
For dynamic OG image generation, you'll need:
| Package | Purpose | Install |
satori | Converts HTML/CSS-like structures to SVG | pnpm add satori |
sharp or @resvg/resvg-js | Converts SVG to PNG/JPEG | pnpm add sharp or pnpm add @resvg/resvg-js |
@vercel/og (optional) | All-in-one wrapper (includes satori + resvg) | pnpm add @vercel/og |
For Astro SSG (build-time generation):
pnpm add satori @resvg/resvg-js Note on JSX vs Plain Objects: The code examples in this blueprint use JSX syntax (common in documentation) for readability. Satori accepts either:
JSX (requires React/Preact or a JSX transform)
Plain JavaScript objects (no dependencies beyond satori itself)
For Astro without React, use the plain object form:
// Plain object — no JSX compiler needed
const element = {
type: 'div',
props: {
style: { display: 'flex', background: '#0f172a', width: '100%', height: '100%' },
children: {
type: 'h1',
props: {
style: { color: 'white', fontSize: '48px' },
children: title
}
}
}
};
const svg = await satori(element, { width: 1200, height: 630, fonts });
const png = await sharp(Buffer.from(svg)).png().toBuffer(); Requirements & Best Practices
Cache aggressively — CDN with 24hr+ TTL; same inputs = same output
Fallback gracefully — If generation fails, fall back to static default image
Keep it fast — Edge runtime preferred; avoid heavy computation
Test across platforms — Validate with Facebook, LinkedIn, Twitter before launch
Deterministic output — Same params must produce identical image (for caching)
Custom Override Escape Hatch
The dynamic system handles 95% of cases, but sometimes you need bespoke:
---
title: "Product Launch Announcement"
description: "Introducing our revolutionary new feature"
shareImage: "/og/campaigns/product-launch-2025.jpg" # Custom override
--- When shareImage is present in frontmatter, use it directly instead of the dynamic endpoint. This gives marketers the escape hatch they need for hero campaigns without requiring it for every page.
Validation & Debugging
Debugging Checklist
View page source — Confirm meta tags are in
<head>Check absolute URLs — All
og:image,og:urlmust be absolute HTTPSVerify image accessibility — Can you open the OG image URL directly?
Test with validators — Use platform-specific tools below
Platform Validators
| Platform | Validator URL | Notes |
| https://developers.facebook.com/tools/debug/ | Also clears Facebook cache | |
| https://www.linkedin.com/post-inspector/ | Forces re-scrape | |
| Twitter/X | https://cards-dev.twitter.com/validator | Preview card appearance |
| Generic | https://metatags.io | Preview across platforms |
| Schema.org | https://validator.schema.org | Validate JSON-LD |
| Google Rich Results | https://search.google.com/test/rich-results | Test structured data |
Cache Busting
When previews show stale data:
Facebook: Use Sharing Debugger "Scrape Again" button
LinkedIn: Post Inspector "Refresh" button
Twitter: Validator automatically re-fetches
WhatsApp: Hardest to bust. Options:
Wait (caches for hours/days)
Change URL (add
?v=2query param)Change
og:imageURL
CI/Automated Checks
Consider adding checks to catch missing metadata:
// Example: Playwright test for OG tags
test('homepage has required OG tags', async ({ page }) => {
await page.goto('/');
const ogTitle = await page.getAttribute('meta[property="og:title"]', 'content');
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
const ogDesc = await page.getAttribute('meta[property="og:description"]', 'content');
expect(ogTitle).toBeTruthy();
expect(ogImage).toMatch(/^https:\/\//);
expect(ogDesc?.length).toBeLessThanOrEqual(155);
}); Performance & Caching
Static images: Long
Cache-Control(1 year) with fingerprinted filenamesDynamic endpoints: Short TTL (5min-1hr) with
stale-while-revalidateCDN: Ensure OG images are served via CDN for fast unfurling
Server-side only: Never compute OG meta on client; it must be in initial HTML
Governance & Maintenance
Ownership: One team/component owns
SITE_SEOconfig and helpersReview: OG metadata should be part of PR review for new pages
Testing: Include preview testing in QA checklist
Documentation: Keep this blueprint updated as platforms evolve
Migration Plan
Audit current state — Check existing pages for OG coverage
Introduce
SITE_SEOconfig — Centralize defaultsAdd
buildOgMeta()helper — Replace scattered meta tag logicAdd
structured-data.ts— Implement JSON-LD for key pagesUpdate
BaseLayout.astro— Centralize renderingSet
siteinastro.config.mjs— Enable absolute URLsAdd validation tests — CI checks for OG presence
Test with validators — Verify all platforms render correctly
Document per-collection defaults — Ensure consistent fallbacks
Checklist
Setup
SITE_SEOconfig defined with all required fieldssiteUrlset correctly for production environmentbuildOgMeta()helper implemented with truncationstructured-data.tshelpers for JSON-LDBaseLayout.astrorenders meta, canonical, and JSON-LD
Per-Page
Title under 60 characters
Description under 155 characters
OG image exists and is 1200x630
OG image URL is absolute HTTPS
og:urlset to canonical URLJSON-LD schema appropriate for content type
Content Collections
Frontmatter schema includes
title,description,shareImageCollection defaults defined for fallback images
Article metadata (dates, author) mapped to OG and JSON-LD
Validation
Tested with Facebook Sharing Debugger
Tested with LinkedIn Post Inspector
Tested with Twitter Card Validator
JSON-LD validated with Schema.org validator
WhatsApp preview manually checked
Production
siteconfigured inastro.config.mjsOG images served via CDN with proper caching
CI checks for OG tag presence (optional but recommended)