<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Headlark]]></title><description><![CDATA[Headlark]]></description><link>https://headlark.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad8c3e8b4dbb33e916e23a1/115fce25-e970-47c7-87ea-457c4340da53.png</url><title>Headlark</title><link>https://headlark.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 09:47:40 GMT</lastBuildDate><atom:link href="https://headlark.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Add JSON-LD structured data to an Astro or Docusaurus site]]></title><description><![CDATA[How to add JSON-LD to Astro, Starlight and Docusaurus pages: which schema.org types fit docs and blogs, working code for each, common mistakes and how to…]]></description><link>https://headlark.hashnode.dev/add-json-ld-structured-data-to-an-astro-or-docusaurus-site</link><guid isPermaLink="true">https://headlark.hashnode.dev/add-json-ld-structured-data-to-an-astro-or-docusaurus-site</guid><category><![CDATA[SEO]]></category><category><![CDATA[Astro]]></category><category><![CDATA[docusaurus]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JSON-LD]]></category><dc:creator><![CDATA[Headlark]]></dc:creator><pubDate>Wed, 21 Oct 2026 14:35:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad8c3e8b4dbb33e916e23a1/43e1a4f3-8744-4389-9feb-1910584f493d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://headlark.com/guides/json-ld-astro-docusaurus">headlark.com</a>.</em></p>
<p>Structured data tells search engines what a page is: an article with an author and a date, a position in a breadcrumb trail, the organization behind the site. Google reads it to show rich results, such as breadcrumbs instead of a bare URL or an article's date and image, and recommends JSON-LD as the format (<a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data">Google Search Central: structured data</a>). It's a <code>&lt;script type="application/ld+json"&gt;</code> block in the page's HTML, so on a static site it belongs in the build output.</p>
<h2>Which types fit a docs site or blog</h2>
<table>
<thead>
<tr>
<th>Page</th>
<th>Type</th>
<th>What it can give you</th>
</tr>
</thead>
<tbody><tr>
<td>Blog post, changelog entry</td>
<td><code>BlogPosting</code> or <code>Article</code></td>
<td>Headline, date, image and author understood correctly (<a href="https://developers.google.com/search/docs/appearance/structured-data/article">Article</a>)</td>
</tr>
<tr>
<td>Any page below the home page</td>
<td><code>BreadcrumbList</code></td>
<td>A breadcrumb trail in desktop results (<a href="https://developers.google.com/search/docs/appearance/structured-data/breadcrumb">Breadcrumb</a>)</td>
</tr>
<tr>
<td>Home page</td>
<td><code>Organization</code>, <code>WebSite</code></td>
<td>Your logo and the site name shown in results</td>
</tr>
<tr>
<td>Product or tool page</td>
<td><code>SoftwareApplication</code></td>
<td>Price and rating details, if the page shows them</td>
</tr>
</tbody></table>
<p>Skip <code>FAQPage</code> and <code>HowTo</code> for rich results: Google no longer shows HowTo rich results and limits FAQ rich results to a small set of authoritative sites. Whatever you mark up has to be visible on the page; markup that describes content readers can't see is against Google's guidelines.</p>
<h2>Astro</h2>
<p>A small component keeps the escaping in one place. JSON inside a <code>&lt;script&gt;</code> isn't HTML-escaped, so a string containing <code>&lt;/script&gt;</code> would end the tag early; replacing <code>&lt;</code> with its Unicode escape prevents that and stays valid JSON.</p>
<pre><code class="language-astro">---
// src/components/JsonLd.astro
const { data } = Astro.props;
const json = JSON.stringify(data).replace(/&lt;/g, '\\u003c');
---
&lt;script type="application/ld+json" set:html={json} /&gt;
</code></pre>
<p>Astro leaves a <code>&lt;script&gt;</code> with any attribute other than <code>src</code> unprocessed, so this ships exactly as written. Use it in a blog post layout, building absolute URLs from <code>site</code> in <code>astro.config.mjs</code>:</p>
<pre><code class="language-astro">---
// src/layouts/BlogPost.astro
import JsonLd from '../components/JsonLd.astro';
const { title, description, pubDate, updatedDate, heroImage, author } = Astro.props;
const url = new URL(Astro.url.pathname, Astro.site).href;
---
&lt;head&gt;
  &lt;JsonLd data={{
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: title,
    description,
    datePublished: pubDate.toISOString(),
    ...(updatedDate &amp;&amp; { dateModified: updatedDate.toISOString() }),
    ...(heroImage &amp;&amp; { image: new URL(heroImage, Astro.site).href }),
    author: { '@type': 'Person', name: author },
    mainEntityOfPage: url,
  }} /&gt;
&lt;/head&gt;
</code></pre>
<p>A breadcrumb trail for a page two levels down looks like this; the last item can leave out <code>item</code>, and Google uses the page's own URL:</p>
<pre><code class="language-js">{
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    { '@type': 'ListItem', position: 1, name: 'Docs', item: 'https://example.com/docs/' },
    { '@type': 'ListItem', position: 2, name: 'Guides', item: 'https://example.com/docs/guides/' },
    { '@type': 'ListItem', position: 3, name: 'Deploy to Netlify' },
  ],
}
</code></pre>
<h2>Starlight</h2>
<p>For a single page, the <code>head</code> front matter field takes the same tag format as the config option:</p>
<pre><code class="language-yaml">---
title: Deploy to Netlify
head:
  - tag: script
    attrs:
      type: application/ld+json
    content: '{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Guides","item":"https://docs.example.com/guides/"},{"@type":"ListItem","position":2,"name":"Deploy to Netlify"}]}'
---
</code></pre>
<p>For every page, override Starlight's <code>Head</code> component and build the data from the route. Register it with <code>components: { Head: './src/components/Head.astro' }</code> in the Starlight config:</p>
<pre><code class="language-astro">---
// src/components/Head.astro
import Default from '@astrojs/starlight/components/Head.astro';
import JsonLd from './JsonLd.astro';
const { entry, lastUpdated } = Astro.locals.starlightRoute;
---
&lt;Default /&gt;
&lt;JsonLd data={{
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: entry.data.title,
  ...(entry.data.description &amp;&amp; { description: entry.data.description }),
  ...(lastUpdated &amp;&amp; { dateModified: lastUpdated.toISOString() }),
}} /&gt;
</code></pre>
<p><code>lastUpdated</code> is only set when you enable <code>lastUpdated</code> in the Starlight config.</p>
<h2>Docusaurus</h2>
<p>Site-wide data, such as your organization, goes in <code>headTags</code> in <code>docusaurus.config.js</code> (<a href="https://docusaurus.io/docs/seo">Docusaurus: SEO</a>):</p>
<pre><code class="language-js">export default {
  headTags: [
    {
      tagName: 'script',
      attributes: { type: 'application/ld+json' },
      innerHTML: JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'Organization',
        name: 'Example',
        url: 'https://example.com/',
        logo: 'https://example.com/img/logo.png',
      }),
    },
  ],
};
</code></pre>
<p>That block is added to every page, so keep it to data that's true everywhere. For one page, put a <code>&lt;head&gt;</code> block in the Markdown or MDX file:</p>
<pre><code class="language-mdx">&lt;head&gt;
  &lt;script type="application/ld+json"&gt;
    {JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'SoftwareApplication',
      name: 'Example CLI',
      operatingSystem: 'macOS, Linux, Windows',
      applicationCategory: 'DeveloperApplication',
    })}
  &lt;/script&gt;
&lt;/head&gt;
</code></pre>
<p>Check your build before adding breadcrumbs or article markup: recent Docusaurus 3 releases already emit <code>BreadcrumbList</code> on docs pages and <code>BlogPosting</code> on blog posts. A second, slightly different copy only adds noise.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Invalid JSON.</strong> Hand-written blocks break on trailing commas, smart quotes and unescaped quotes in titles. Build the object in code and serialize it with <code>JSON.stringify</code>; Google ignores a block it can't parse.</li>
<li><strong>Relative URLs.</strong> <code>"image": "/hero.png"</code> isn't a URL a crawler can resolve reliably. Use full URLs from your site setting.</li>
<li><strong>Missing <code>@context</code> or <code>@type</code>.</strong> Without <code>"@context": "https://schema.org"</code> the block means nothing to search engines.</li>
<li><strong>Dates without a time zone.</strong> Use ISO 8601 (<code>toISOString()</code> gives UTC), not "October 21".</li>
<li><strong>Data that isn't on the page.</strong> Ratings, prices or authors in the markup must also appear in the visible content.</li>
<li><strong>Duplicates.</strong> A theme, a plugin and your own component each emitting an <code>Article</code> for the same page, with different headlines.</li>
</ul>
<h2>Validate it</h2>
<p>List built pages with no JSON-LD at all (Astro and Starlight build to <code>dist</code>, Docusaurus to <code>build</code>):</p>
<pre><code class="language-bash">grep -rL --include='*.html' 'application/ld+json' dist
</code></pre>
<p>Then test a few pages of each template. The <a href="https://headlark.com/tools/structured-data-checker">free structured data checker</a> takes a URL or pasted markup and checks each block against the properties Google requires and recommends. Google's <a href="https://search.google.com/test/rich-results">Rich Results Test</a> shows which rich results a page qualifies for, and the <a href="https://validator.schema.org/">Schema Markup Validator</a> checks the markup against schema.org. The <a href="https://headlark.com/tools/seo-checker">free SEO checker</a> lists a live page's JSON-LD types and flags blocks that aren't valid JSON or lack a <code>@context</code> or <code>@type</code>. After deploying, Search Console's rich result reports show errors across the whole site.</p>
<h2>Automating it with Headlark</h2>
<p>Hand-written markup works for a few templates. On a site with hundreds of different pages, Headlark's <a href="https://headlark.com/docs/build">build step</a> can write it from each page's content after your build:</p>
<ul>
<li>One to three JSON-LD blocks per page, using a type Google supports for rich results when the content fits (such as <code>Article</code>, <code>BreadcrumbList</code>, <code>Organization</code>, <code>Product</code>, <code>SoftwareApplication</code>), otherwise <code>WebSite</code> or <code>WebPage</code>. Never <code>FAQPage</code> or <code>HowTo</code>.</li>
<li>Only properties whose values appear on the page, with URLs made absolute from your <code>--site-url</code>. A block that isn't valid JSON or has no <code>@type</code> is dropped, not written.</li>
<li>A type you already emit is kept as it is and never added twice, so it works alongside the Docusaurus defaults or your own component.</li>
</ul>
<p>Run your key templates through the Rich Results Test once; it takes minutes when the markup is already there.</p>
<h2>Try it</h2>
<p>The free plan covers one site and 25 pages. <a href="https://headlark.com/app">Add your site</a>, copy its secret key, and your next build ships with SEO.</p>
]]></content:encoded></item><item><title><![CDATA[SEO for Starlight docs sites]]></title><description><![CDATA[Set up SEO on a Starlight docs site: site and descriptions, og:image via head, a Head override, the sitemap, hreflang on multilingual docs and JSON-LD.]]></description><link>https://headlark.hashnode.dev/seo-for-starlight-docs-sites</link><guid isPermaLink="true">https://headlark.hashnode.dev/seo-for-starlight-docs-sites</guid><category><![CDATA[Astro]]></category><category><![CDATA[SEO]]></category><category><![CDATA[documentation]]></category><category><![CDATA[webdev]]></category><category><![CDATA[starlight]]></category><dc:creator><![CDATA[Headlark]]></dc:creator><pubDate>Wed, 21 Oct 2026 14:30:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad8c3e8b4dbb33e916e23a1/ae8f4be6-c8da-4757-9056-d2dd936c5ed6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://headlark.com/guides/starlight-seo">headlark.com</a>.</em></p>
<p>Starlight, the docs theme for Astro, gets more SEO right by default than most docs generators. Every page is static HTML with a <code>&lt;title&gt;</code>, <code>og:title</code>, <code>og:type</code>, <code>og:locale</code>, <code>og:site_name</code> and <code>twitter:card</code> set to <code>summary_large_image</code>. With a site URL configured it adds a canonical link, <code>og:url</code>, a sitemap and, on multilingual sites, <code>hreflang</code> links. Three things are left to you: a description for each page, a preview image, and anything beyond those tags. This guide covers each one, checked against Starlight 0.42 (<a href="https://starlight.astro.build/reference/configuration/">Starlight configuration</a>).</p>
<h2>1. Set <code>site</code> first</h2>
<p>Starlight reads your production URL from Astro's <code>site</code> option. Without it, pages have no canonical link and no <code>og:url</code>, and no sitemap is written.</p>
<pre><code class="language-js">// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';

export default defineConfig({
  site: 'https://docs.example.com',
  integrations: [
    starlight({
      title: 'Example Docs',
      description: 'Guides and reference for the Example CLI.',
    }),
  ],
});
</code></pre>
<p>If the docs live under a path, such as a GitHub Pages project site, set <code>base</code> as well (<code>base: '/repo'</code>); canonicals and sitemap entries include it. Check that <code>site</code> is the production domain in the environment that deploys, not a preview URL.</p>
<h2>2. Titles and descriptions</h2>
<p>Every page needs a <code>title</code> in its front matter; <code>description</code> is optional (<a href="https://starlight.astro.build/reference/frontmatter/">frontmatter reference</a>):</p>
<pre><code class="language-yaml">---
title: Configure the CLI
description: Every option in the config file, with defaults and an example for monorepos.
---
</code></pre>
<ul>
<li>The <code>&lt;title&gt;</code> is the page title, <code>titleDelimiter</code> (default <code>|</code>), then the site title: "Configure the CLI | Example Docs". Keep page titles distinct; the suffix is the same everywhere.</li>
<li>The page's <code>description</code> becomes both <code>&lt;meta name="description"&gt;</code> and <code>og:description</code>.</li>
<li>A page without one gets the site-wide <code>description</code> from the Starlight config. That's fine for the home page, but on a site where most pages skip the field, most pages share one description. <a href="https://headlark.com/guides/duplicate-meta-descriptions-docs">Duplicate titles and descriptions on a docs site</a> shows how to find those pages in <code>dist</code>.</li>
</ul>
<p>To replace the generated title on one page, put a <code>title</code> tag in its <code>head</code> front matter (next section). Use this sparingly: the visible heading and the search result should usually match.</p>
<h2>3. The <code>head</code> option and a preview image</h2>
<p>Starlight doesn't set <code>og:image</code>, so links to your docs show a card without a picture until you add one. The <code>head</code> option in the config adds tags to every page:</p>
<pre><code class="language-js">starlight({
  title: 'Example Docs',
  head: [
    {
      tag: 'meta',
      attrs: { property: 'og:image', content: 'https://docs.example.com/og.png' },
    },
    {
      tag: 'meta',
      attrs: { name: 'twitter:image', content: 'https://docs.example.com/og.png' },
    },
  ],
}),
</code></pre>
<p>Use a full URL: entries are converted straight to HTML, so nothing makes a relative path absolute. Put the file in <code>public/</code> so its name doesn't change between builds, and use a PNG or JPG around 1200×630, not an SVG.</p>
<p>The <code>head</code> front matter field takes the same format for one page:</p>
<pre><code class="language-yaml">---
title: Migrating from v2
head:
  - tag: meta
    attrs:
      property: og:image
      content: https://docs.example.com/og/migrating.png
---
</code></pre>
<p>Starlight merges the three sources in order: its defaults, then the config <code>head</code>, then the page's <code>head</code>. A <code>&lt;meta&gt;</code> with the same <code>name</code> or <code>property</code>, a <code>&lt;title&gt;</code> or a canonical link replaces the earlier one instead of being added twice, so the page's <code>og:image</code> wins over the site default. The same works for a per-page <code>noindex</code>:</p>
<pre><code class="language-yaml">head:
  - tag: meta
    attrs:
      name: robots
      content: noindex
</code></pre>
<p><code>pagefind: false</code> only hides a page from Starlight's own search; it has no effect on search engines. For other reasons a card can stay blank, see <a href="https://headlark.com/guides/og-image-not-showing">og:image not showing</a>.</p>
<h2>4. Overriding the Head component</h2>
<p>When tags depend on the page, such as a generated image per doc, override Starlight's <code>Head</code> component. It renders the merged list from the page's route data, so you can render the default and add to it:</p>
<pre><code class="language-astro">---
// src/components/Head.astro
import Default from '@astrojs/starlight/components/Head.astro';

const { id, head } = Astro.locals.starlightRoute;
const hasImage = head.some((t) =&gt; t.attrs?.property === 'og:image');
const image = new URL(`/og/${id || 'index'}.png`, Astro.site).href;
---
&lt;Default /&gt;
{!hasImage &amp;&amp; (
  &lt;&gt;
    &lt;meta property="og:image" content={image} /&gt;
    &lt;meta name="twitter:image" content={image} /&gt;
  &lt;/&gt;
)}
</code></pre>
<pre><code class="language-js">starlight({
  title: 'Example Docs',
  components: { Head: './src/components/Head.astro' },
}),
</code></pre>
<p>The check keeps any image set in front matter or config. Only reference image files that exist: an <code>og:image</code> that returns 404 is worse than none, because previewers give up on the card. Starlight's <a href="https://starlight.astro.build/guides/route-data/">route data middleware</a> is an alternative: it can change <code>head</code> before any component renders.</p>
<h2>5. The sitemap</h2>
<p>Starlight adds <code>@astrojs/sitemap</code> itself, so with <code>site</code> set, <code>astro build</code> writes <code>sitemap-index.xml</code> and <code>sitemap-0.xml</code> to <code>dist</code>, and every page links to the index with <code>&lt;link rel="sitemap"&gt;</code>. On a multilingual site the sitemap lists each page's language versions too.</p>
<p>To change it, for example to leave out pages you've marked <code>noindex</code>, add the integration yourself; Starlight then doesn't add its own copy:</p>
<pre><code class="language-js">import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://docs.example.com',
  integrations: [
    starlight({ title: 'Example Docs' }),
    sitemap({
      filter: (page) =&gt; !page.startsWith('https://docs.example.com/internal/'),
    }),
  ],
});
</code></pre>
<p>Your own copy doesn't get Starlight's language settings, so on a multilingual site pass <code>i18n: { defaultLocale, locales }</code> to it with the same locales. Add <code>Sitemap: https://docs.example.com/sitemap-index.xml</code> to <code>public/robots.txt</code> and submit the index in Search Console.</p>
<h2>6. Multilingual sites and hreflang</h2>
<p>With more than one locale configured and <code>site</code> set, every page gets a <code>&lt;link rel="alternate" hreflang="…"&gt;</code> for each locale, using each locale's <code>lang</code>, plus an <code>x-default</code> that points to the default locale. <code>og:locale</code> is set to the page's language.</p>
<p>Starlight links every locale, translated or not. A page missing in one language is still built at that locale's URL, showing the default-language content with a "This content is not available in your language yet" notice (<a href="https://starlight.astro.build/guides/i18n/">Starlight i18n</a>). So <code>hreflang</code> can point Google at a German URL that contains English text, and each such page has its own canonical. On a site with many untranslated pages, that's a lot of near-duplicate URLs. Translate the pages that matter in each language first, and use the route data's <code>isFallback</code> flag in a <code>Head</code> override if you want fallback pages to carry <code>noindex</code>.</p>
<h2>7. Structured data</h2>
<p>Starlight emits no JSON-LD. A <code>script</code> tag with <code>type: application/ld+json</code> works in the config or front matter <code>head</code> for fixed data, and the <code>Head</code> override can build an <code>Article</code> or <code>BreadcrumbList</code> for every page from the route. <a href="https://headlark.com/guides/json-ld-astro-docusaurus">JSON-LD for Astro and Docusaurus</a> has the Starlight code, the types worth using and how to validate them.</p>
<h2>8. Check the build</h2>
<p>Pages still on the site-wide description, and pages without a preview image:</p>
<pre><code class="language-bash">grep -rlF --include='*.html' 'content="Guides and reference for the Example CLI."' dist
grep -rL --include='*.html' 'property="og:image"' dist
</code></pre>
<p>Replace the first string with your own site description. After deploying, the <a href="https://headlark.com/tools/seo-checker">free SEO checker</a> shows a page's Google result and its LinkedIn, Slack and X cards, and the <a href="https://headlark.com/tools/site-checker">site checker</a> runs the same checks on up to 50 pages from your sitemap.</p>
<h2>Automating it with Headlark</h2>
<p>The configuration above takes an afternoon. A description and alt text for every page of a large docs site is the work that doesn't get finished. Headlark's <a href="https://headlark.com/docs/build">build step</a> runs after <code>astro build</code> on the HTML in <code>dist</code>:</p>
<pre><code class="language-json">"build": "astro build &amp;&amp; headlark dist --site-url https://docs.example.com"
</code></pre>
<p>Use the same address as <code>site</code>, including <code>base</code> if you set one, and set <code>HEADLARK_KEY</code> in the build environment. The <a href="https://headlark.com/guides/astro">Astro guide</a> has the details.</p>
<ul>
<li>Each page gets a title, a description and Open Graph and X tags written from its own content, so pages stop inheriting the site description. Images without <code>alt</code> get alt text.</li>
<li>Your canonical, robots directives and JSON-LD types are kept, and so is Starlight's 404 page, which Headlark skips.</li>
<li>An existing <code>og:image</code> is kept. Without one, Headlark picks the page's most representative image, never an SVG. It doesn't create images, so for branded cards, generate them and set <code>og:image</code> as above.</li>
</ul>
<h2>Try it</h2>
<p>The free plan covers one site and 25 pages. <a href="https://headlark.com/app">Add your site</a>, copy its secret key, and your next build ships with SEO.</p>
]]></content:encoded></item><item><title><![CDATA[SEO for Docusaurus: titles, descriptions, link previews and structured data]]></title><description><![CDATA[The Docusaurus SEO settings that matter: front matter, themeConfig.image, head tags, noindex, sitemap ignorePatterns, trailingSlash pitfalls and JSON-LD.]]></description><link>https://headlark.hashnode.dev/seo-for-docusaurus-titles-descriptions-link-previews-and-structured-data</link><guid isPermaLink="true">https://headlark.hashnode.dev/seo-for-docusaurus-titles-descriptions-link-previews-and-structured-data</guid><category><![CDATA[docusaurus]]></category><category><![CDATA[SEO]]></category><category><![CDATA[documentation]]></category><category><![CDATA[webdev]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Headlark]]></dc:creator><pubDate>Wed, 21 Oct 2026 14:25:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad8c3e8b4dbb33e916e23a1/723a6e72-9a63-4bf9-8178-fc2c32ce79d4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://headlark.com/guides/docusaurus-seo">headlark.com</a>.</em></p>
<p>Docusaurus renders a static HTML file for every route, so crawlers and link previewers get real content without running JavaScript. Out of the box each page also gets a <code>&lt;title&gt;</code>, a canonical link, <code>og:url</code>, <code>twitter:card</code> set to <code>summary_large_image</code>, and <code>hreflang</code> links when you have more than one locale. What it can't do on its own is write a good description for every page, pick a preview image per page, or decide which pages belong in search. This guide goes through each setting in order, for Docusaurus 3 (<a href="https://docusaurus.io/docs/seo">Docusaurus: SEO</a>).</p>
<h2>1. Site URL and trailing slashes</h2>
<p>Every absolute URL Docusaurus writes (canonical, <code>og:url</code>, <code>og:image</code>, sitemap entries) is built from <code>url</code> and <code>baseUrl</code> in <code>docusaurus.config.js</code>. Set them to the production address:</p>
<pre><code class="language-js">export default {
  url: 'https://docs.example.com',
  baseUrl: '/',
  trailingSlash: true,
};
</code></pre>
<p>Set <code>trailingSlash</code> explicitly. With the default (<code>undefined</code>) Docusaurus leaves links as written and emits <code>/docs/intro/index.html</code> for <code>docs/intro.md</code>, but the canonical and sitemap URL is <code>/docs/intro</code> without a slash. Many static hosts, GitHub Pages among them, answer that URL with a redirect to <code>/docs/intro/</code>. The result is a canonical that points at a redirect, and Search Console reports such as "Page with redirect" or "Alternate page with proper canonical tag".</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>File written for <code>docs/intro.md</code></th>
<th>URL in canonical and sitemap</th>
</tr>
</thead>
<tbody><tr>
<td><code>true</code></td>
<td><code>build/docs/intro/index.html</code></td>
<td><code>/docs/intro/</code></td>
</tr>
<tr>
<td><code>false</code></td>
<td><code>build/docs/intro.html</code></td>
<td><code>/docs/intro</code></td>
</tr>
<tr>
<td><code>undefined</code></td>
<td><code>build/docs/intro/index.html</code></td>
<td><code>/docs/intro</code> (as linked)</td>
</tr>
</tbody></table>
<p>Pick the value that matches how your host serves those files without redirecting; the Docusaurus deployment docs point to the <a href="https://github.com/slorber/trailing-slash-guide">trailing slash guide</a>, which lists the behavior of each host. Then check one page:</p>
<pre><code class="language-bash">curl -sI https://docs.example.com/docs/intro | grep -iE '^(HTTP|location)'
</code></pre>
<p>A 200 means the canonical URL is served directly. A 301 or 308 means <code>trailingSlash</code> and your host disagree.</p>
<h2>2. Site-wide metadata and the default preview image</h2>
<p>Two settings in <code>themeConfig</code> apply to every page (<a href="https://docusaurus.io/docs/api/themes/configuration">theme configuration</a>):</p>
<pre><code class="language-js">export default {
  themeConfig: {
    image: 'img/social-card.png',
    metadata: [
      {name: 'twitter:site', content: '@example'},
    ],
  },
};
</code></pre>
<ul>
<li><strong><code>image</code></strong> is the default <code>og:image</code> and <code>twitter:image</code>, a path relative to the <code>static</code> folder (or a full URL). Docusaurus makes it absolute from <code>url</code>. It can't be an SVG; Facebook, LinkedIn and X won't render one. Use a PNG or JPG around 1200×630.</li>
<li><strong><code>metadata</code></strong> is a list of <code>&lt;meta&gt;</code> tags; every field is passed straight to the tag, and an entry overrides a default with the same name, such as <code>twitter:card</code>.</li>
</ul>
<p>Without <code>image</code>, pages have no preview image at all unless the doc sets one. If a card still comes out without a picture, <a href="https://headlark.com/guides/og-image-not-showing">og:image not showing</a> covers the other causes.</p>
<h2>3. Per-page front matter</h2>
<p>The docs, blog and pages plugins all accept the same four SEO fields (<a href="https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#markdown-front-matter">docs front matter</a>):</p>
<pre><code class="language-yaml">---
title: Configure the CLI
description: Every option in the config file, with defaults and an example for monorepos.
image: /img/social/configure.png
keywords: [cli, configuration]
sidebar_label: Configuration
---
</code></pre>
<ul>
<li><strong><code>title</code></strong> becomes <code>&lt;title&gt;</code> (followed by the site title) and <code>og:title</code>. Without it Docusaurus uses the Markdown <code>#</code> heading, then the doc id. <code>sidebar_label</code> keeps a short name in the sidebar while the title says what the page is.</li>
<li><strong><code>description</code></strong> becomes both <code>&lt;meta name="description"&gt;</code> and <code>og:description</code>. Front matter is the better place for it than a hand-written tag, which would need both.</li>
<li><strong><code>image</code></strong> becomes this page's <code>og:image</code>, replacing <code>themeConfig.image</code>.</li>
<li><strong><code>keywords</code></strong> becomes a <code>&lt;meta name="keywords"&gt;</code> tag. Google doesn't use that tag for ranking, so leave it out unless your own site search reads it.</li>
</ul>
<h3>The first-line description</h3>
<p>If a doc has no <code>description</code>, Docusaurus uses the first line of Markdown content. Imports, code blocks and the <code>#</code> title are skipped, but other headings, admonitions and short intro sentences aren't. A page that opens with <code>## Prerequisites</code> gets "Prerequisites" as its description, and so does every other page that opens the same way. To see what your build produced:</p>
<pre><code class="language-bash">grep -rhoE --include='*.html' '&lt;meta name="description" content="[^"]*"' build | sort | uniq -c | sort -rn | head
</code></pre>
<p>Any description with a count above 1 is shared. <a href="https://headlark.com/guides/duplicate-meta-descriptions-docs">Duplicate titles and descriptions on a docs site</a> goes through finding and fixing them, including versioned docs.</p>
<h2>4. Other head tags</h2>
<p>For tags the front matter doesn't cover, there are three places, from widest to narrowest.</p>
<p><strong>Every page:</strong> <code>headTags</code> in <code>docusaurus.config.js</code>, a list of objects with <code>tagName</code>, <code>attributes</code> and optionally <code>innerHTML</code>:</p>
<pre><code class="language-js">export default {
  headTags: [
    {
      tagName: 'link',
      attributes: {rel: 'preconnect', href: 'https://fonts.example.com'},
    },
  ],
};
</code></pre>
<p><strong>One Markdown or MDX page:</strong> a <code>&lt;head&gt;</code> block in the file itself:</p>
<pre><code class="language-mdx"># Migrating from v2

&lt;head&gt;
  &lt;meta property="og:type" content="article" /&gt;
&lt;/head&gt;
</code></pre>
<p><strong>A React page</strong> in <code>src/pages</code>: the <code>@docusaurus/Head</code> component. The default <code>Layout</code> also takes <code>title</code> and <code>description</code> props, which fill the same tags as front matter:</p>
<pre><code class="language-jsx">import Layout from '@theme/Layout';
import Head from '@docusaurus/Head';

export default function Pricing() {
  return (
    &lt;Layout title="Pricing" description="Plans and limits for the hosted version."&gt;
      &lt;Head&gt;
        &lt;meta property="og:image" content="https://docs.example.com/img/social/pricing.png" /&gt;
      &lt;/Head&gt;
      {/* page content */}
    &lt;/Layout&gt;
  );
}
</code></pre>
<p>Tags set this way go into the static HTML at build time, so crawlers see them.</p>
<h2>5. Keeping pages out of search</h2>
<ul>
<li><strong>One page:</strong> <code>&lt;head&gt;&lt;meta name="robots" content="noindex" /&gt;&lt;/head&gt;</code> in the file. The sitemap plugin leaves out pages with a <code>noindex</code> robots tag.</li>
<li><strong>A page you link to privately:</strong> <code>unlisted: true</code> in front matter. It's built, but hidden from the sidebar, marked not to be indexed and left out of the sitemap.</li>
<li><strong>An old docs version:</strong> <code>noIndex: true</code> on that version in the docs plugin's <code>versions</code> option.</li>
<li><strong>The whole site</strong> (a staging deployment): <code>noIndex: true</code> at the top level of the config adds <code>noindex, nofollow</code> to every page and skips the sitemap. Make sure it's off in production.</li>
</ul>
<p>Don't use <code>robots.txt</code> for this: it stops crawling, not indexing, and a blocked page can't show Google its <code>noindex</code>.</p>
<h2>6. The sitemap</h2>
<p><code>@docusaurus/plugin-sitemap</code> is part of <code>preset-classic</code> and writes <code>sitemap.xml</code> during <code>docusaurus build</code> (<a href="https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-sitemap">plugin-sitemap</a>). Configure it through the preset:</p>
<pre><code class="language-js">presets: [
  [
    'classic',
    {
      sitemap: {
        lastmod: 'date',
        changefreq: null,
        priority: null,
        ignorePatterns: ['/tags/**', '/blog/page/**'],
      },
    },
  ],
],
</code></pre>
<ul>
<li><strong><code>ignorePatterns</code></strong> takes globs matched against route paths. With a <code>baseUrl</code> other than <code>/</code>, include it in the pattern (<code>/repo/tags/**</code>). Tag and paginated list pages are the usual candidates: thin pages that compete with your docs.</li>
<li><strong><code>lastmod</code></strong> (<code>'date'</code> or <code>'datetime'</code>) adds <code>&lt;lastmod&gt;</code> from each file's last update. Google uses <code>lastmod</code> when it's consistently accurate and ignores <code>changefreq</code> and <code>priority</code>, so setting those two to <code>null</code> just makes the file smaller.</li>
<li><strong><code>createSitemapItems</code></strong> is a function for filtering or changing items when globs aren't enough.</li>
</ul>
<p>Point crawlers at it with a line in <code>static/robots.txt</code>, <code>Sitemap: https://docs.example.com/sitemap.xml</code>, and submit it in Search Console.</p>
<h2>7. Structured data</h2>
<p>Recent Docusaurus 3 releases already emit <code>BreadcrumbList</code> JSON-LD on docs pages and <code>BlogPosting</code> on blog posts, so check a built page before adding your own. Site-wide data such as an <code>Organization</code> goes in <code>headTags</code> as a <code>script</code> with <code>type: 'application/ld+json'</code>; one-off data goes in the page's <code>&lt;head&gt;</code> block. <a href="https://headlark.com/guides/json-ld-astro-docusaurus">JSON-LD for Astro and Docusaurus</a> has working code, the types worth using and how to validate them.</p>
<h2>8. Check the build</h2>
<p>Everything above ends up in the HTML in <code>build</code>, so check that rather than the config. After <code>npm run build</code>, list pages without a description and pages without a preview image:</p>
<pre><code class="language-bash">grep -rL --include='*.html' 'name="description"' build
grep -rL --include='*.html' 'property="og:image"' build
</code></pre>
<p>Then check a few deployed pages with the <a href="https://headlark.com/tools/seo-checker">free SEO checker</a>, which shows the Google result and the LinkedIn, Slack and X cards each page produces, or the <a href="https://headlark.com/tools/site-checker">site checker</a> for up to 50 pages from your sitemap at once.</p>
<h2>Automating it with Headlark</h2>
<p>The settings above are one-time work. Writing a distinct title, description and image alt text for each of hundreds of docs is the part that doesn't get done. Headlark's <a href="https://headlark.com/docs/build">build step</a> runs after <code>docusaurus build</code> on the HTML in <code>build</code>:</p>
<pre><code class="language-json">"build": "docusaurus build &amp;&amp; headlark build --site-url https://docs.example.com"
</code></pre>
<p>The first <code>build</code> after <code>headlark</code> is the output folder. If you set a <code>baseUrl</code> such as <code>/repo/</code>, include it: <code>--site-url https://example.github.io/repo/</code>. Set <code>HEADLARK_KEY</code> in the build environment.</p>
<ul>
<li>Each page gets a title, a description and Open Graph and X tags written from its own content, and alt text for images that have none.</li>
<li>Your canonical, robots directives and JSON-LD types are kept, so the Docusaurus defaults and your <code>noindex</code> pages stay as they are.</li>
<li>An existing <code>og:image</code>, from front matter or <code>themeConfig.image</code>, is kept. Headlark doesn't create images: with only <code>themeConfig.image</code> set, every page keeps that one image, so set <code>image</code> on the docs that deserve their own card.</li>
<li>Docusaurus switches pages in the browser after the first load and applies its own title from route data, so the tab may show your original title after a click. Crawlers and link previewers load each page's HTML and get the rewritten tags.</li>
</ul>
<h2>Try it</h2>
<p>The free plan covers one site and 25 pages. <a href="https://headlark.com/app">Add your site</a>, copy its secret key, and your next build ships with SEO.</p>
]]></content:encoded></item><item><title><![CDATA[Duplicate titles and meta descriptions on a docs site]]></title><description><![CDATA[Why Docusaurus, Starlight and VitePress sites end up with the same title or description on many pages, how to find them in your build, and how to fix them.]]></description><link>https://headlark.hashnode.dev/duplicate-titles-and-meta-descriptions-on-a-docs-site</link><guid isPermaLink="true">https://headlark.hashnode.dev/duplicate-titles-and-meta-descriptions-on-a-docs-site</guid><category><![CDATA[SEO]]></category><category><![CDATA[documentation]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Astro]]></category><category><![CDATA[docusaurus]]></category><dc:creator><![CDATA[Headlark]]></dc:creator><pubDate>Wed, 21 Oct 2026 14:20:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad8c3e8b4dbb33e916e23a1/ea5f4690-2aa2-4e83-b631-a46faf10e8bb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://headlark.com/guides/duplicate-meta-descriptions-docs">headlark.com</a>.</em></p>
<p>Docs sites grow by copying pages. A new section starts as a copy of an old one, every product area gets an "Overview" and a "Configuration" page, and the framework fills in whatever the front matter leaves out. The result is dozens of pages that tell search engines the same thing in their <code>&lt;title&gt;</code> and <code>&lt;meta name="description"&gt;</code>.</p>
<p>Google asks for a distinct description per page and descriptive, concise titles (<a href="https://developers.google.com/search/docs/appearance/snippet">Google Search Central: snippets</a>, <a href="https://developers.google.com/search/docs/appearance/title-link">title links</a>). When fifty pages share one description, Google writes its own snippet from the page text, and search results for your docs look alike, so people have a harder time picking the right page.</p>
<h2>Where the duplicates come from</h2>
<h3>Docusaurus</h3>
<p>Each doc's description comes from its <code>description</code> front matter. Without it, Docusaurus uses the first line of Markdown content as the description (<a href="https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#markdown-front-matter">docs front matter</a>). It skips imports, code blocks and the <code>#</code> title, but not everything else, so these patterns produce repeats:</p>
<ul>
<li>Pages that open with the same <code>## Overview</code> or <code>## Prerequisites</code> heading get "Overview" or "Prerequisites" as their description.</li>
<li>Pages that open with the same admonition (<code>:::note</code> followed by "This feature is in beta.") all get that sentence.</li>
<li>Versioned docs repeat every page once per version, with identical titles and descriptions.</li>
</ul>
<p>Titles come from <code>title</code> front matter, then the Markdown <code>#</code> heading, then the doc id, followed by the site title. Two pages called "Configuration" in different sections become two identical "Configuration | My Project" titles.</p>
<h3>Starlight</h3>
<p>Every page has a required <code>title</code>; <code>description</code> is optional. The site-wide <code>description</code> in your Starlight config is used for every page that doesn't set its own (<a href="https://starlight.astro.build/reference/configuration/#description">Starlight configuration</a>). So the more pages you have without front matter descriptions, the more pages share the site description.</p>
<h3>VitePress</h3>
<p>The <code>description</code> in <code>.vitepress/config</code> renders on every page, and a page's front matter <code>description</code> overrides it (<a href="https://vitepress.dev/reference/site-config#description">VitePress site config</a>). A page's title is its front matter <code>title</code> or the text of its first <code>&lt;h1&gt;</code>, plus the <code>titleTemplate</code> suffix. The same failure modes as Starlight: one description everywhere, and repeated headings become repeated titles.</p>
<h2>Find them in your build</h2>
<p>Check the HTML your framework produced, not the source files: that's what crawlers read, and it includes the fallbacks. Build first, then point these at the output folder (<code>build</code> for Docusaurus, <code>dist</code> for Starlight, <code>.vitepress/dist</code> for VitePress).</p>
<p>Descriptions used on more than one page, with a count:</p>
<pre><code class="language-bash">grep -rhoE --include='*.html' '&lt;meta name="description" content="[^"]*"' build | sort | uniq -cd | sort -rn
</code></pre>
<p>Titles used on more than one page:</p>
<pre><code class="language-bash">grep -rhoE --include='*.html' '&lt;title[^&gt;]*&gt;[^&lt;]*' build | sed 's/&lt;title[^&gt;]*&gt;//' | sort | uniq -cd | sort -rn
</code></pre>
<p>To see which files share a given description:</p>
<pre><code class="language-bash">grep -rlF --include='*.html' 'content="Overview"' build
</code></pre>
<p>Attribute order and quoting vary between frameworks; if a command prints nothing, open one built page and adjust the pattern to match its tag. For the live site, the <a href="https://headlark.com/tools/site-checker">free site checker</a> reads your sitemap and lists every duplicated title and description with the pages that share it, across up to 50 pages. For a single live page, the <a href="https://headlark.com/tools/seo-checker">free SEO checker</a> shows the title and description exactly as crawlers get them.</p>
<h2>Fix them per framework</h2>
<h3>Docusaurus</h3>
<p>Set both fields in the front matter of each doc. <code>description</code> also becomes <code>og:description</code>.</p>
<pre><code class="language-yaml">---
title: Configure the CLI
description: Every option in headlark.config.json, with defaults and an example for monorepos.
sidebar_label: Configuration
---
</code></pre>
<p><code>title</code> is for the browser tab and search results; <code>sidebar_label</code> keeps the short name in the sidebar. For old versions you don't want in search results, set <code>noIndex</code> on the version in the docs plugin options:</p>
<pre><code class="language-js">// docusaurus.config.js, inside the docs plugin or preset options
docs: {
  versions: {
    '1.x': { noIndex: true },
  },
},
</code></pre>
<h3>Starlight</h3>
<pre><code class="language-yaml">---
title: Configure the CLI
description: Every option in headlark.config.json, with defaults and an example for monorepos.
---
</code></pre>
<p>Keep the site-wide <code>description</code> in <code>astro.config.mjs</code> for the home page, and treat any other page that still shows it as unfinished. Starlight joins the page and site titles with <code>titleDelimiter</code> (default <code>|</code>); the page title is the part that has to be unique.</p>
<h3>VitePress</h3>
<pre><code class="language-yaml">---
title: Configure the CLI
description: Every option in headlark.config.json, with defaults and an example for monorepos.
---
</code></pre>
<p>To catch pages that still inherit the site description, a <code>transformPageData</code> hook in <code>.vitepress/config.mts</code> can list them during the build:</p>
<pre><code class="language-js">export default defineConfig({
  description: 'Docs for the example CLI',
  transformPageData(pageData) {
    if (!pageData.frontmatter.description) {
      console.warn(`no description: ${pageData.relativePath}`)
    }
  },
})
</code></pre>
<h2>Writing ones that stay unique</h2>
<ul>
<li><strong>Lead with what the page is about, not the section.</strong> "Configure the CLI" beats "Configuration"; "Deploy to Netlify" beats "Netlify".</li>
<li><strong>Put the distinguishing detail in the description:</strong> the option names, the platform, the error message the reader searched for. Aim for one or two sentences, roughly 70 to 155 characters, so it isn't cut off.</li>
<li><strong>Don't fill descriptions with keywords.</strong> Google is explicit that keyword lists don't help.</li>
<li><strong>Reused partials and generated pages</strong> (API references, changelogs per version) need a description built from the data that differs, such as the class name or version number.</li>
<li><strong>Re-run the duplicate check in CI</strong> so a copied page doesn't reintroduce the problem.</li>
</ul>
<h2>Automating it with Headlark</h2>
<p>Writing and maintaining a description for every page is the part that slips on a large docs site. Headlark's <a href="https://headlark.com/docs/build">build step</a> runs after your framework's build, on the HTML in <code>build</code>, <code>dist</code> or <code>.vitepress/dist</code>:</p>
<pre><code class="language-json">"build": "docusaurus build &amp;&amp; headlark build --site-url https://docs.example.com"
</code></pre>
<ul>
<li>Each page gets a title and description written from that page's own content, so two pages only end up alike if they say the same thing.</li>
<li>Your canonical, robots directives and JSON-LD types are kept; an empty result never replaces one of your tags.</li>
<li>Unchanged pages come from cache on every rebuild.</li>
<li>Docusaurus and VitePress switch pages in the browser after the first load and can apply their own title from their route data, so the browser tab may show your original title after a click. Crawlers and link previewers load each page's HTML and see the rewritten tags.</li>
</ul>
<p>Review the results on your most visited pages; it takes minutes when the rest is done.</p>
<h2>Try it</h2>
<p>The free plan covers one site and 25 pages. <a href="https://headlark.com/app">Add your site</a>, copy its secret key, and your next build ships with SEO.</p>
]]></content:encoded></item><item><title><![CDATA[og:image not showing in Slack, LinkedIn or X]]></title><description><![CDATA[Why a shared link shows a card with no image in Slack, LinkedIn or X, the usual causes, and how to set a working og:image in Astro, Next.js and Hugo.]]></description><link>https://headlark.hashnode.dev/og-image-not-showing-in-slack-linkedin-or-x</link><guid isPermaLink="true">https://headlark.hashnode.dev/og-image-not-showing-in-slack-linkedin-or-x</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[openGraph]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[Hugo]]></category><dc:creator><![CDATA[Headlark]]></dc:creator><pubDate>Wed, 21 Oct 2026 14:19:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad8c3e8b4dbb33e916e23a1/5dd0e948-8e6d-4e16-a078-cf2534bccaf9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://headlark.com/guides/og-image-not-showing">headlark.com</a>.</em></p>
<p>You share a link and the card has a title and a description but no picture, or a tiny thumbnail instead of a large image. The image in a link preview comes from the page's <code>og:image</code> tag (and <code>twitter:image</code> for X). When it's missing, unreachable or in a format the previewer rejects, the card drops it without telling you why. This guide covers the image specifically; for the full set of tags, see <a href="https://headlark.com/guides/open-graph-tags">Open Graph tags for every page</a>.</p>
<h2>The usual causes</h2>
<ul>
<li><strong>A relative URL.</strong> <code>&lt;meta property="og:image" content="/og.png"&gt;</code> doesn't work reliably. The <a href="https://ogp.me/">Open Graph protocol</a> expects a full URL, starting with <code>https://</code> and your domain.</li>
<li><strong>The wrong domain.</strong> The URL points to <code>localhost</code>, a preview deployment or a staging host, because the site URL wasn't set for the production build.</li>
<li><strong>An SVG.</strong> Facebook, LinkedIn and X don't render SVG preview images. Use JPG, PNG, WebP or GIF.</li>
<li><strong>Too small or too heavy.</strong> LinkedIn shows images under 401 pixels wide as a thumbnail and recommends 1200×627 at most 5 MB (<a href="https://www.linkedin.com/help/linkedin/answer/46687">LinkedIn: make your website shareable</a>). An image around 1200×630 works on every network.</li>
<li><strong>The image is blocked.</strong> The page loads in your browser, but the previewer's request for the image gets a 403, a login page or a bot challenge: password-protected previews, firewall bot rules, or hotlink protection on the image host.</li>
<li><strong>Added by JavaScript.</strong> Previewers read the HTML the server sends and don't run scripts. A tag set at runtime in a single-page app never reaches them.</li>
<li><strong>A cached card.</strong> LinkedIn and Facebook keep a preview for days. You fixed the tag, but the card still shows the old one until you ask them to fetch the page again.</li>
<li><strong><code>name</code> instead of <code>property</code>.</strong> The Open Graph spec uses <code>property="og:image"</code>; X's tags use <code>name="twitter:image"</code>. Some previewers accept either, but not all, so follow the spec.</li>
</ul>
<h2>Check what the previewer gets</h2>
<p>First the tag, from the raw HTML:</p>
<pre><code class="language-bash">curl -s https://example.com/blog/post | grep -oE '&lt;meta[^&gt;]+(og|twitter):image[^&gt;]*&gt;'
</code></pre>
<p>Then the image itself: it should return 200 and an image content type, with no redirect to a login page.</p>
<pre><code class="language-bash">curl -sI https://example.com/og/post.png | grep -iE '^(HTTP|content-type|content-length)'
</code></pre>
<p>The <a href="https://headlark.com/tools/seo-checker">free SEO checker</a> does both from a server, the way previewers do, and shows the LinkedIn, Facebook, Slack and X cards it would produce. After a fix, refresh the cached card in <a href="https://www.linkedin.com/post-inspector/">LinkedIn Post Inspector</a> and the <a href="https://developers.facebook.com/tools/debug/">Facebook Sharing Debugger</a> (Scrape Again).</p>
<h2>Astro</h2>
<p>Set <code>site</code> in <code>astro.config.mjs</code> to your production URL, then build the absolute URL in your layout with <code>Astro.site</code>:</p>
<pre><code class="language-astro">---
// src/layouts/Base.astro
const { title, description, image = '/og/default.png' } = Astro.props;
const imageURL = new URL(image, Astro.site).href;
---
&lt;head&gt;
  &lt;meta property="og:title" content={title} /&gt;
  &lt;meta property="og:description" content={description} /&gt;
  &lt;meta property="og:image" content={imageURL} /&gt;
  &lt;meta name="twitter:card" content="summary_large_image" /&gt;
  &lt;meta name="twitter:image" content={imageURL} /&gt;
&lt;/head&gt;
</code></pre>
<p>Without <code>site</code>, <code>Astro.site</code> is undefined and <code>new URL</code> throws on a relative path, which is better than shipping a broken tag. Put shared images in <code>public/</code> so their URL doesn't change between builds; an image imported through <code>astro:assets</code> gets a hashed file name, so pass its <code>.src</code> to <code>new URL</code> rather than writing the path by hand.</p>
<p>Starlight sets <code>og:title</code>, <code>og:description</code> and <code>twitter:card</code> but no image by default. Add one for every page with the <code>head</code> option:</p>
<pre><code class="language-js">starlight({
  title: 'My Docs',
  head: [
    { tag: 'meta', attrs: { property: 'og:image', content: 'https://docs.example.com/og.png' } },
  ],
})
</code></pre>
<h2>Next.js (App Router)</h2>
<p>Set <code>metadataBase</code> once in the root layout. Relative image paths in metadata are then resolved against it; without it, a relative path fails the build (<a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase">Next.js: metadataBase</a>).</p>
<pre><code class="language-tsx">// app/layout.tsx
export const metadata = {
  metadataBase: new URL('https://example.com'),
  openGraph: { images: '/og/default.png' },
}
</code></pre>
<p>The pitfall: metadata from layouts and pages is merged shallowly. A page that sets any <code>openGraph</code> field replaces the whole <code>openGraph</code> object from the layout, image included:</p>
<pre><code class="language-tsx">// app/blog/[slug]/page.tsx: this page loses the layout's og:image
export const metadata = {
  openGraph: { title: 'My post' },
}
</code></pre>
<p>Repeat the image in the page's <code>openGraph</code>, spread a shared object into it, or use the file convention: an <code>opengraph-image.png</code> (or <code>.jpg</code>) in a route folder applies to that route and the routes below it.</p>
<h2>Hugo</h2>
<p>Use Hugo's embedded templates in your <code>&lt;head&gt;</code>:</p>
<pre><code class="language-go-html-template">{{ partial "opengraph.html" . }}
{{ partial "twitter_cards.html" . }}
</code></pre>
<p>They take the image from the page's <code>images</code> front matter, then from a page resource whose name contains <code>feature</code>, <code>cover</code> or <code>thumbnail</code>, then from <code>params.images</code> in your site configuration (<a href="https://gohugo.io/templates/embedded/">Hugo: embedded templates</a>). Give the site a default:</p>
<pre><code class="language-toml"># hugo.toml
baseURL = 'https://example.com/'
[params]
  images = ['og/default.png']
</code></pre>
<pre><code class="language-yaml">---
title: My post
images: ['og/my-post.png']
---
</code></pre>
<p>Absolute URLs are built from <code>baseURL</code>, so check it's the production address in the environment that deploys. Older themes call the templates as <code>{{ template "_internal/opengraph.html" . }}</code>; recent Hugo versions use the partial form shown above.</p>
<h2>If the tag is right and the card still has no image</h2>
<ol>
<li>Open the image URL in a private window. If you see a login page, so does the previewer.</li>
<li>Check your host's bot protection and any password protection on the domain you shared.</li>
<li>Look for two <code>og:image</code> tags with different values, for example one from your theme and one from a plugin. Previewers usually take the first.</li>
<li>Ask LinkedIn and Facebook to fetch the page again. Slack and X also cache cards, so a link shared before the fix may keep its old preview.</li>
</ol>
<h2>Automating it with Headlark</h2>
<p>Headlark's <a href="https://headlark.com/docs/build">build step</a> writes the link preview tags into each page's HTML after your build. For the image it's deliberately conservative:</p>
<ul>
<li>It keeps your page's existing <code>og:image</code> if there is one, otherwise it picks the page's most representative image.</li>
<li>It makes the URL absolute from your <code>--site-url</code> and never uses an SVG or a <code>data:</code> URL, so a page whose only image is an SVG logo gets a small card.</li>
<li>It doesn't create images. If you want branded cards, generate them yourself and set <code>og:image</code>; Headlark then fills in the title, description and the rest around it.</li>
</ul>
<h2>Try it</h2>
<p>The free plan covers one site and 25 pages. <a href="https://headlark.com/app">Add your site</a>, copy its secret key, and your next build ships with SEO.</p>
]]></content:encoded></item></channel></rss>