Skip to content
AstroCraft Docs
On this theme

SEO & Structured Data

Develi’s <head> is owned, not vendored. src/layouts/BaseHead.astro emits every meta, Open Graph, Twitter and structured-data tag with native tags and no SEO dependency. There is no astro-seo, no robots plugin, no schema package.

Do not add one. The pieces are already here, and the reason they are worth keeping is that every URL in them derives from a single setting, so none of them can drift from the others.

Every page goes through BaseLayout

<BaseLayout
  title="Work — Develi"
  description="Case studies from Develi: trading interfaces, freight booking…"
  image={heroImage}
  noindex={false}
  schema={[article]}
  article={{ published, modified, author }}
>

title and description are required and should be unique per page, pulled from typed config or content frontmatter rather than a site-wide default. They are still the two tags that move rankings.

image takes an ImageMetadata — a bundled image, not a string — which is what lets BaseHead emit real dimensions:

const imageWidth = image?.width ?? 1200;
const imageHeight = image?.height ?? 630;

For a page with no image of its own it falls back to siteData.defaultImage and the 1200×630 convention. If you replace public/og.jpg with different dimensions, those fallback tags start lying — keep it 1200×630.

Canonical and og:url cannot disagree

Both derive from one expression in BaseHead:

const canonicalURL = new URL(Astro.url.pathname, Astro.site);

Never reconstruct a URL by hand anywhere else. Where a route needs the canonical for a JSON-LD node, it derives it exactly the same way, so the <link rel="canonical"> and the graph’s url always match.

With trailingSlash: "always" and the directory build, both emit the trailing-slash form. The build, the canonical and the sitemap agree on one URL shape, which is the whole point.

What BaseHead emits

Charset, viewport, generator. A <link rel="preload"> for the variable font, with the href imported rather than hardcoded so it carries the hashed build URL. Both favicons. The sitemap link and an RSS <link rel="alternate">.

Then the SEO block: <title>, <meta name="description">, the canonical link, and a robots meta only when noindex is set.

Then Open Graph — og:type (which flips to article when the article prop is present), title, description, url, site name, locale, and the image with alt, width and height. og:locale is derived from siteLocale with the hyphen swapped for an underscore, because Open Graph wants en_US where BCP-47 gives en-US.

Then Twitter — summary_large_image, title, description, image, image alt, and twitter:creator from siteData.author.twitter when set.

Then the JSON-LD block, then the pre-paint theme script, then the view-transitions router when enabled.

The article prop

article={{ published: pubDate, modified: updatedDate, author: byline?.data.name }}

Produces og:type=article, article:published_time, article:modified_time and article:author. Only the blog post route uses it.

Set modified when you meaningfully revise a post. A stale dateModified hurts more than none at all.

Structured data — use the builders

BaseHead emits an Organization + WebSite @graph on every page, automatically, from siteData. You do not have to do anything for that.

const siteNodes = getSiteSchema({
  name: siteData.name,
  url: canonicalURL.href,
  description: siteData.description,
  inLanguage: siteLocale,
  logo: new URL(siteData.defaultImage.src, canonicalURL).href,
  sameAs: siteData.sameAs,
});
const jsonLd = serializeJsonLd([...siteNodes, ...(schema ?? [])]);

The two nodes are linked by stable @ids — https://yoursite.com/#organization and #website — so the WebSite declares its publisher rather than describing an unrelated second entity.

sameAs comes from siteData.sameAs, which is derived from navData.social. A placeholder URL there ships a placeholder claim to search engines. Drop the rows you do not have.

The available builders

src/js/schema.ts exports six, all typed with explicit return types and an options interface:

Builder Produces
getOrganizationSchema Organization, keyed by a stable @id
getWebSiteSchema WebSite, linked to its publisher
getSiteSchema both of the above, composed — used by BaseHead
getArticleSchema BlogPosting
getBreadcrumbSchema BreadcrumbList
organizationId the stable Organization @id from any site URL

Plus serializeJsonLd, which escapes < so a content value cannot break out of the script tag.

Never inline a raw <script type="application/ld+json">. The builders set the stable @ids that let nodes cross-reference, and they handle the escaping. Adding a new schema type means adding a typed builder beside the others.

Passing page-specific schema

const article = getArticleSchema({
  headline: title,
  description,
  url: canonical,
  image: imageUrl,
  datePublished: pubDate.toISOString(),
  dateModified: updatedDate?.toISOString(),
  authorName: byline?.data.name ?? "",
  authorUrl: byline?.data.authorLink,
  inLanguage: siteLocale,
  publisherId: organizationId(canonical),
});
---
<BaseLayoutschema={[article]} article={{ … }}>

schema is always an array, even for one node. The prop takes JsonLdNode[] rather than a node-or-array union, so BaseHead has nothing to normalize — the union saved one call site a pair of brackets and cost a ternary on every render.

publisherId ties the node to the Organization BaseHead already emits, rather than describing a second unlinked publisher.

Two builders that ship deliberately unused

getBreadcrumbSchema requires a visible breadcrumb nav. Emitting a BreadcrumbList on a page with no on-screen breadcrumb is markup and schema disagreeing, which is the one thing structured data must never do. The builder is the schema counterpart to the ui/breadcrumb/ primitive — build the nav first, then pass the node. Do not reach for it because it happens to exist.

Case-study pages emit no per-page schema at all, and that is a decision rather than a gap. The obvious candidate is getArticleSchema, but a case study in this design has no author and no publication date, so the node would ship with two effectively-required fields either empty or invented. If your case studies gain a real date, add a CreativeWork builder beside the others rather than filling getArticleSchema’s gaps with plausible values.

Crawlability

robots.txt and llms.txt are dynamic endpoints, not files in /public, so their absolute URLs resolve against site and can never drift. Both prerender to static files at build.

export const GET: APIRoute = ({ site }) => {
  const lines = ["User-agent: *", "Allow: /"];
  if (site) lines.push("", `Sitemap: ${new URL("sitemap-index.xml", site).href}`);
  return new Response(lines.join("\n") + "\n", { … });
};

Extend the Disallow list for any app-only path you add.

llms.txt is an editorial content map, not a second sitemap. It lists the core pages, the feed, every case study, and the ten most recent blog posts. The cap on posts exists because an archive grows without bound; case studies are deliberately uncapped because that collection is curated, stays small, and is the most useful thing on the site for a retrieval system to have in full.

It is not a ranking factor. Curate it as the site grows — add docs and changelogs as they land.

The sitemap is @astrojs/sitemap at /sitemap-index.xml, linked from BaseHead. Its filter excludes the two noindex routes:

sitemap({ filter: (page) => !page.includes("/examples/") && !page.includes("/404/") })

A noindex page must also be excluded from the sitemap. If you add one, add it to that filter in the same commit.

RSS

/rss.xml is a hand-rolled RSS 2.0 endpoint — see Blog & RSS for the details. It is linked from three places per the theme’s own rules: BaseHead, llms.txt and the footer.

SITE_URL feeds all of it

Canonical, OG, JSON-LD @ids, the sitemap, robots.txt, llms.txt and RSS. One variable, seven consumers. A production deploy throws on the placeholder — see Deployment.

hreflang

None is emitted, because the site is single-language and hreflang is only meaningful with two or more locales. Re-adding i18n means re-adding per-locale alternates and an x-default in BaseHead together with the rest of the locale layer. See Pages & Routing.

Core Web Vitals

The theme is static HTML with almost no client JavaScript, so the usual wins are already in place. What is on you:

  • Always set alt, always ship intrinsic width/height. astro:assets does the second for you when you pass a bundled image. See Images & Assets.
  • Do not lazy-load above-the-fold media. The hero or LCP image wants loading="eager". The variable font is already preloaded; preload a hero the same way if it is your LCP.
  • Third-party scripts are where a fast static site loses its lead. Add them deliberately.

The check

pnpm build

Then confirm in dist/ — a fully static tree, so it is flat rather than dist/client/:

  • Valid JSON-LD in any page’s <head>: a @graph with matching @ids. Paste it into Google’s Rich Results Test.
  • robots.txt and llms.txt with absolute URLs on your real domain.
  • sitemap-0.xml listing only indexable, trailing-slash URLs.
  • dist/blog/<slug>/index.html carrying og:type=article, article:published_time and a BlogPosting node.

The JSON-LD builders also carry a runnable self-check:

pnpm test
# or directly:
node --experimental-strip-types src/js/schema.test.ts
NEXT STEPContact Form