Skip to content
AstroCraft Docs
On this theme

SEO & Structured Data

Grafio’s SEO layer is owned, not vendored. BaseHead emits every meta and Open Graph tag natively, structured data comes from typed builders in src/js/schema.ts, and robots.txt and llms.txt are dynamic endpoints. There is no astro-seo, no robots package, no schema library.

Astro already solved the performance half of technical SEO. Metadata, structured data and crawl clarity are the half that is on you, and this is where they live.

One rule above all others

Every indexable page goes through BaseLayout with a unique title and description. Those are the two tags that still move rankings. Pull them from typed config or content frontmatter; never let a subpage inherit the site default.

BaseHead appends nothing to your title. The convention in the shipped routes is:

const title = `Work — ${siteData.name}`;

with the home page using siteData.title verbatim, because it is the site.

What BaseHead emits

The full list, in document order: charset and viewport; two font preloads; SVG and ICO favicons; the sitemap link; the RSS alternate link; <title>; the meta description; the canonical link; a conditional noindex robots tag; nine Open Graph tags including real image dimensions; a conditional article:* block; five Twitter card tags plus a conditional creator; the JSON-LD graph; the pre-paint theme script; and <ClientRouter /> when view transitions are on.

Canonical and og:url are computed once and shared, so they cannot disagree:

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

Astro.site comes from SITE_URL. Because trailingSlash: "always" and the build format is directory, the pathname already carries its trailing slash — do not reconstruct URLs by hand anywhere else.

Open Graph image dimensions come from the real bundled ImageMetadata when a page passes an image, and fall back to the 1200×630 convention otherwise:

const socialImage = new URL(image?.src ?? siteData.defaultImage.src, Astro.site);
const imageWidth = image?.width ?? 1200;
const imageHeight = image?.height ?? 630;

Which is why public/og.jpg should actually be 1200×630 — the fallback dimensions are asserted, not measured.

noindex is a prop

<BaseLayout title={title} description={description} noindex={true}>

That emits <meta name="robots" content="noindex, nofollow" />. No positive index, follow tag is ever emitted, because the absence of a robots tag already means index.

A noindex page must also be excluded from the sitemap, or the two contradict each other. The filter lives in astro.config.mjs:

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

Add yours there when you add a noindex route.

Structured data

Every page automatically carries an Organization and a WebSite node in a single @graph, built from siteData. You do nothing for that.

For page-specific schema, build a node with a builder and pass it via the schema prop:

---
import { getArticleSchema, organizationId } from "@js/schema";

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

const article = getArticleSchema({
  headline: post.data.title,
  description: post.data.description,
  url: canonical,
  image: new URL(post.data.heroImage.src, Astro.site).href,
  datePublished: post.data.pubDate.toISOString(),
  dateModified: post.data.updatedDate?.toISOString(),
  authorName: author.data.name,
  authorUrl: author.data.authorLink,
  inLanguage: siteLocale,
  publisherId: organizationId(canonical),
});
---

<BaseLayout
  title={title}
  description={post.data.description}
  image={post.data.heroImage}
  schema={[article]}
  article={{ published: post.data.pubDate, modified: post.data.updatedDate }}
>

schema is always an array, even for one node. The prop takes JsonLdNode[], not a node-or-array union, so BaseHead has nothing to normalise.

Two builders exist for page nodes:

  • getArticleSchema produces a BlogPosting. Used by the post route.
  • getCreativeWorkSchema produces a CreativeWork. Used by case studies. It takes a discipline that becomes genre, and it deliberately emits no datePublished — a work entry has a display order, not a publication date, and a date invented for the graph is a date that is wrong.

organizationId(url) returns <origin>/#organization. Passing the page’s own canonical is fine; only the origin is used. That stable @id is how a page node references the publisher without duplicating the organisation on every page.

Never hand-write a JSON-LD block

Two reasons the builders exist. They set stable @ids so nodes cross-reference correctly. And serializeJsonLd escapes every < to <, so a value containing </script> cannot break out of the inline tag:

return JSON.stringify({ "@context": "https://schema.org", "@graph": [...nodes] })
  .replace(/</g, "\\u003c");

If you need a new schema type, write a typed builder beside the others — explicit return type, options interface — and give it a case in src/js/schema.test.ts.

Fill in sameAs

Organization.sameAs disambiguates your organisation to search engines, and it derives from siteData.socials. Fill that list with real profile URLs. Edit socials; never edit sameAs.

The article prop

Passing article flips og:type from website to article and emits the timestamps:

interface ArticleMeta {
  published: Date;
  modified?: Date;
  author?: string;
}

Note these are Date objects, while the schema builder takes ISO strings. The blog route sets both from the same fields, so the Open Graph modified time and the JSON-LD dateModified appear and disappear together.

dateModified is never defaulted to datePublished. Set it when you actually revise something. A stale modification date hurts more than none.

Crawlability endpoints

robots.txt is a dynamic endpoint, not a file in public/, so its Sitemap: URL resolves against site and cannot drift:

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", {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
};

There are no Disallow rules out of the box. Add them to that array for any app-only path.

llms.txt is an editorial content map for AI crawlers — not a sitemap and not a ranking factor. Its core-page list is hand-curated on purpose; its Writing list is derived from the collection so it cannot fall behind. Curate the first as your site grows.

The sitemap comes from @astrojs/sitemap at /sitemap-index.xml, linked from BaseHead. It cannot enumerate on-demand routes, so a page you make server-rendered may need a manual entry.

One trailing-slash shape. The directory build, the canonical tag and the Open Graph URL all emit the trailing-slash form. Keep them agreeing.

Two deliberate omissions

No hreflang. The site is single-language — the i18n layer was removed — and hreflang is only meaningful with two or more locales. Re-adding internationalisation means restoring per-locale alternates and x-default in BaseHead together with the rest of the layer, not piecemeal.

No BreadcrumbList, and no builder for one. This is a rule, not a gap. A breadcrumb schema should be paired with a visible breadcrumb navigation so markup and structured data agree, and no page in this design has one. The builder was written, sat unused, started being cited in other files’ documentation as though it were part of the pattern, and was deleted. If you add breadcrumb navigation, write the builder back — in that order.

Verifying

Run pnpm build, then check three things in dist/ — note the Netlify adapter emits a flat tree, not dist/client/:

  • A page’s <head> contains a valid JSON-LD @graph with matching @ids and your real domain.
  • robots.txt and llms.txt carry absolute URLs.
  • sitemap-0.xml lists only indexable, trailing-slash URLs.

The builders carry their own self-check: pnpm test, or directly node --experimental-strip-types src/js/schema.test.ts.

Before shipping a new schema type, validate the output in Google’s Rich Results Test.

Images and vitals

Optimise through astro:assets with sources in src/assets. Always set alt. Always ship intrinsic width and height — that is what kills layout shift.

Do not lazy-load above-the-fold media; the hero image on each route uses loading="eager". The variable fonts are already preloaded in BaseHead; preload a hero image the same way if it is your largest contentful paint.

Keep islands small and hydrate late. Third-party scripts are where a fast Astro site loses its lead — add them deliberately, not by default.

NEXT STEPContact Form