SEO & Structured Data
Finly’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, no RSS package — @astrojs/sitemap is the only integration involved, and it only generates the sitemap.
The governing rule is the same one the motion catalog follows: the best tag is the one the layout already emits. Astro solved performance; metadata, structured data and crawl clarity are the part that is on you.
What BaseHead emits
Every page passes title and description through BaseLayout to BaseHead, and gets:
<title>,<meta name="description">and a canonical link<meta name="robots" content="noindex, nofollow">when the page passesnoindex- Open Graph —
og:type,title,description,url,site_name,locale, andog:imagewith real width and height - Twitter —
summary_large_image, title, description, image and alt, plustwitter:creatoronly when a handle is configured article:published_timeandarticle:modified_timeon article pages- hreflang alternates plus
x-default, andog:locale:alternate— the whole block dropping out whilelocales.length === 1 - a JSON-LD
@graph - the font preload, both favicons, the sitemap link and the RSS link
- the inline pre-paint theme script, and
<ClientRouter />when view transitions are on
Canonical and og:url cannot disagree, because both derive from one expression:
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
Nothing elsewhere reconstructs a URL by hand. Where a component needs the canonical — the blog post rebuilds it for its share intents and schema @ids — it goes through the same helper that built the route.
og:image dimensions are real, not assumed. When a page passes a bundled ImageMetadata, its actual width and height are emitted; only the site-wide default falls back to the 1200×630 convention. That is why heroImage is required in the blog and customers schemas: it lets the image be passed unconditionally, so every post and every story has a correct social card.
hreflang is written and inert. The block is complete and simply does not render at one locale. Adding a second locale to siteSettings turns it on with no edit here.
The JSON-LD layer
src/js/schema.ts holds dependency-free builders. Each returns a plain node; getSiteSchema composes the site-level graph that every page emits, and BaseHead merges any page-specific nodes into one @graph and serializes once.
Five page-level builders ship:
| Builder | Used by |
|---|---|
getArticleSchema |
blog posts |
getCaseStudySchema |
customer stories |
getJobPostingSchema |
careers adverts |
getBreadcrumbSchema |
every detail page, paired with a visible breadcrumb |
getOrganizationSchema / getWebSiteSchema |
automatic, on every page |
Using one is a prop:
---
import { getArticleSchema, getBreadcrumbSchema } from "@js/schema";
const article = getArticleSchema({ headline, description, url: canonical, datePublished, authorName, inLanguage });
const crumbs = getBreadcrumbSchema([
{ name: "Blog", url: blogUrl },
{ name: post.data.title, url: canonical },
]);
---
<BaseLayout title={…} description={…} schema={[article, crumbs]} article={{ published, modified, author }}>
Three rules go with it.
Never inline a raw <script type="application/ld+json">. The builders set stable @ids so nodes cross-reference inside the graph, and serializeJsonLd escapes < so a value cannot break out of the tag. Adding a schema type means adding a typed builder beside the others, with an explicit return type and an options interface.
Pair every BreadcrumbList with a visible breadcrumb, so the markup and the schema agree about the page’s place in the site.
Send dateModified only when it is true. A stale dateModified hurts more than none, which is why the post page passes it only when the entry declares an updatedDate.
The careers page shows the other half of that principle. salaryRange returns null on a band it cannot parse, so the advert ships with no baseSalary rather than with a guess — absent beats wrong. Meanwhile officesFor throws, because a JobPosting with no jobLocation is not a job posting worth emitting. Which failure mode is right depends on whether a partial node is still useful.
The whole module carries a runnable self-check:
node --experimental-strip-types src/js/schema.selfcheck.ts
It runs as part of pnpm test.
Two fields that ship empty
siteData.sameAs and siteData.author.twitter are both empty, and that is deliberate. Finly is an invented company, and every plausible value for either points at a real stranger’s account. BaseHead renders twitter:creator only when the handle is truthy, and getOrganizationSchema omits sameAs entirely when the array is empty — so an unfilled field costs nothing and a wrong one misattributes the site.
Fill them the day the accounts exist. sameAs is what disambiguates the Organization node, so it is worth doing.
The crawl surfaces
robots.txt is a dynamic endpoint rather than a /public file, so its Sitemap: line resolves against site and can never drift from the real domain. It prerenders to a static file at build. Today it is Allow: / plus that line, and there are no app-only paths to disallow — which is correct rather than unfinished.
sitemap-index.xml / sitemap-0.xml come from @astrojs/sitemap, with two pieces of configuration that both exist to keep it honest:
sitemap({
filter: (page) => !["/examples/", "/404/", "/login/", "/signup/", "/forgot-password/"]
.some((path) => page.includes(path)),
customPages: ON_DEMAND_ROUTES.map((route) => new URL(route, SITE).href),
})
The filter drops everything that sets noindex in the markup — a sitemap entry would contradict the tag. A login form has nothing to rank and a signup form is a funnel entrance, not a landing page. customPages names /contact/ by hand, because the sitemap enumerates built pages and that route emits no file; dropping an indexable page silently is the failure this prevents. The result is 119 indexable URLs.
llms.txt is a curated content map for AI retrieval systems, and it stays curated. It is an editorial index, not a second sitemap and not a ranking factor:
# Finly
> See every euro the moment it moves. …
## Product
- [Product overview](…): the three surfaces — corporate cards, bill pay and the close — on one ledger.
- [Integrations](…): the accounting, payroll, banking and identity systems Finly connects to.
…
The sitemap already enumerates every URL, so a generated list here would add nothing and would bury the handful of pages that actually answer “what is this”. The per-entry sentence is what does the work. Adding a new page type means adding one line, not generating the tail — the thirteen posts, ten stories and sixty connectors stay out on purpose.
rss.xml is hand-rolled RSS 2.0 over the blog collection, covered in Blog & RSS.
All three are src/pages/*.ts endpoints that prerender to static files, and all three read site for their absolute URLs.
One trailing-slash shape
trailingSlash: "always" in astro.config.mjs, and everything agrees with it: the directory build, getLocalizedRoute, canonical, hreflang, og:url and every href in navData. The one exception is /rss.xml, a file rather than a directory.
This is not theoretical tidiness. getLocalizedRoute was normalizing the footer’s correctly-declared /rss.xml into /rss.xml/, which 404s under this setting — and the link-integrity check is what found it.
CSP is deliberately off
security.checkOrigin is set explicitly to true — it rejects a form POST whose Origin does not match the request URL, which covers form content types on on-demand routes. It is on by default in current Astro; stating it means it survives a config edit.
security.csp is deliberately not enabled, because it is incompatible with <ClientRouter />, which BaseHead mounts. If you drop view transitions, that trade changes.
Note also that checkOrigin is not the whole CSRF story, which is why the contact action re-validates its own input rather than treating the setting as sufficient.
The check
Run pnpm build, then look at dist/client/:
- valid JSON-LD in each page’s
<head>— a@graphwhose@ids cross-reference robots.txtandllms.txtcarrying absolute URLs on your domainsitemap-0.xmllisting only indexable, trailing-slash URLsrss.xmlparsing in a feed reader
Validate any new schema type in Google’s Rich Results Test before shipping it. And run pnpm test, which includes the schema self-check and the theme-contrast check — accessibility failures are SEO failures often enough to be worth catching in the same pass.