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),
});
---
<BaseLayout … schema={[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 intrinsicwidth/height.astro:assetsdoes 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@graphwith matching@ids. Paste it into Google’s Rich Results Test. robots.txtandllms.txtwith absolute URLs on your real domain.sitemap-0.xmllisting only indexable, trailing-slash URLs.dist/blog/<slug>/index.htmlcarryingog:type=article,article:published_timeand aBlogPostingnode.
The JSON-LD builders also carry a runnable self-check:
pnpm test
# or directly:
node --experimental-strip-types src/js/schema.test.ts