Blog

Astro SEO: meta tags, schema, hreflang, sitemaps and redirects, from the code behind dardo.studio

Astro needs no SEO plugin: one head component, a JSON-LD graph, honest sitemap dates and real 301s. Our site's code, and four SEO bugs we fixed in it.

By Nicolás Cerón ·

A lamplit observatory at blue hour where a crimson thread pinned across a star chart traces one route between the stars.
On this page

The short answer

Astro already does the hardest part of technical SEO: it pre-renders every page to complete HTML, so crawlers read your content without running JavaScript. What's left is yours to write: a title, description and canonical on every page, hreflang if you publish in more than one language, structured data, a sitemap whose dates are true, permanent redirects and a check for broken links. There is no official "Astro SEO plugin"; the only official SEO integration is @astrojs/sitemap.

dardo.studio is an Astro site, and we handle all of this with one head component of about 250 lines, a sitemap built from our CMS, a few lines in the Worker that serves it, and a link check that runs at the end of every build. On 17 October 2026 we crawled all 300 URLs in our sitemap and found four problems no plugin would have caught. This guide shows the code, adapted from the site's source, and those four fixes.

What Astro does for SEO, and what it leaves to you

NeedWhat Astro gives youWhat we added on dardo.studio
Content in the HTMLPages pre-rendered at build by defaultNothing: every page except a draft preview is built ahead
Title, description, canonicalFull control of <head>, no defaultsOne head component for every page
hreflangi18n routing for URLs, no hreflang tagsLanguage versions read from the CMS
Structured dataNothing built inOne JSON-LD graph per page
Sitemap@astrojs/sitemap, an official integrationA sitemap the CMS builds, with alternates and images
Images<Image /> and <Picture /> with srcset, sizes and dimensionsShared widths for artwork so phones get smaller files
RedirectsA redirects setting301s from the Worker, including missing trailing slashes
Broken linksNothing built inA build step that fails on links to pages that weren't built

Google's JavaScript SEO basics explain why the first row matters: Googlebot can render JavaScript, but pages wait in a render queue first, and "server-side or pre-rendering is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript." Astro gives you that by default. Everything else in the table is configuration and data.

Do you need an Astro SEO plugin?

Not for the tags. A plugin saves you typing a dozen <meta> elements; it can't know your canonical address, which pages are translations of each other or when a page really changed. That knowledge lives in your content and routes. Here is what people install, with npm's download counts for 12 to 18 October 2026:

PackageWhat it doesLatest versionDownloads that week
@astrojs/sitemapOfficial integration: writes a sitemap from your built routes3.7.42,461,416
astro-seoA component that writes title, meta, Open Graph and Twitter tags1.2.064,615
astro-robots-txtWrites robots.txt at build1.0.015,603
astro-seo-schemaA component that writes JSON-LD7.0.19,539
astro-seo-metaAnother meta tags component7.0.12,659

If you're starting out, astro-seo and astro-seo-schema are fine. We wrote our own component because every value in it comes from the CMS: the brand name, the studio's address, the founder, each page's translations and dates. The component itself is the easy part.

Titles, descriptions, robots and the canonical

Every page on dardo.studio renders the same head component with a title, a description and the page's language. Adapted from SeoHead.astro:

---
const titleLimit = ["ja", "ko", "zh-Hans", "zh-Hant"].includes(lang) ? 32 : 60;
// Add the brand only where it still fits in a search result.
const displayTitle =
  /\bdardo\b/i.test(title) || title.length + 8 > titleLimit ? title : `${title} — Dardo`;
// Absolute, built from the page's own path: https://dardo.studio/en/blog/astro-seo/
const canonical = new URL(Astro.url.pathname, "https://dardo.studio").href;
---
<title>{displayTitle}</title>
<meta name="description" content={description} />
<meta name="robots" content={noindex ? "noindex, follow" : "index, follow, max-image-preview:large"} />
<link rel="canonical" href={canonical} />

Three decisions sit in those lines:

  • The brand suffix is conditional. Google's title link guidance asks for descriptive, concise titles and says titles are truncated to fit the device. A long title keeps its words; a short one gets " — Dardo".
  • The canonical is absolute and self-referencing. Google recommends absolute URLs in rel="canonical" and ranks redirects and canonical tags as strong signals, sitemap inclusion as a weak one. The Web Almanac 2025 found a canonical on 67% of mobile pages, so about a third of mobile pages still leave this to chance.
  • The canonical follows the URL format. Our config sets trailingSlash: "always", and Astro's default directory output makes Astro.url.pathname end in a slash, so the canonical matches the address the host actually serves. If your canonical says /about and your host serves /about/, you've told Google two different things.

max-image-preview:large is a robots meta rule that lets Google show large image previews; without it, Google decides on its own.

hreflang from your content, not from folder names

dardo.studio is in English, Spanish and Portuguese, and the slugs are translated: /en/services/website-migration/ is /es/servicios/migracion-de-pagina-web/ in Spanish. A rule that swaps /en/ for /es/ would point Google at pages that don't exist. So the head component asks the CMS which entries are translations of each other:

---
// [{ lang: "en", path: "/en/blog/llms-txt/" }, { lang: "es", path: "/es/blog/que-es-llms-txt/" }, …]
const versions = noindex ? [] : versionsOf(new URL(canonical).pathname);
const xDefault = versions.find((v) => v.lang === "en") ?? versions[0];
---
{versions.length > 1 && (
  <>
    {versions.map((v) => <link rel="alternate" hreflang={v.lang} href={absolute(v.path)} />)}
    <link rel="alternate" hreflang="x-default" href={absolute(xDefault.path)} />
  </>
)}

Google's hreflang rules say each language version must list itself and every other version, with fully-qualified URLs. Building the list from one set of translations per page guarantees that both directions agree. A page kept out of search gets no alternates, and a page published in one language gets none, because a one-item set says nothing.

Astro's i18n routing helps you build localized URLs but doesn't write these tags, and @astrojs/sitemap's i18n option matches languages by "a locale part in a page path", which works when slugs are identical across languages and fails when they're translated. Our multilingual SEO guide covers URL structure and the common hreflang errors.

One JSON-LD graph per page

Each page carries one JSON-LD block whose nodes point at each other by @id, so the organization, the website and the page are described once and linked, not repeated:

const schema = {
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "WebSite", "@id": `${site}/#website`, name: "Dardo", url: `${site}/`,
      inLanguage: ["en", "es", "pt"], publisher: { "@id": `${site}/#organization` } },
    { "@type": "Organization", "@id": `${site}/#organization`, name: "Dardo",
      address: { "@type": "PostalAddress", addressLocality: "Bogotá", addressCountry: "CO" },
      founder: { "@type": "Person", "@id": `${site}/#founder`, name: "Nicolás Cerón" } },
    { "@type": isArticle ? "Article" : "WebPage", "@id": canonical, url: canonical,
      isPartOf: { "@id": `${site}/#website` },
      ...(isArticle && { datePublished, dateModified, author: { "@id": `${site}/#founder` } }) },
  ],
};
// Escape "<" so a title containing "</script>" can't end the tag early.
const json = JSON.stringify(schema).replace(/</g, "\\u003c");
<script is:inline type="application/ld+json" set:html={json}></script>

Articles add a BreadcrumbList and, when they have questions, an FAQPage. Since 17 October the blog, work and tools hubs are a CollectionPage with an ItemList of what they link to. The founder's profile is a ProfilePage whose person is the same #founder node. One detail cost us a failed validation: our ProfilePage dates were plain days ("2026-10-14"), which Google's validator rejected, so date-only values now become noon UTC (2026-10-14T12:00:00Z).

What to expect from all this:

Sitemaps: lastmod has to be true

dardo.studio doesn't use @astrojs/sitemap. A static route, src/pages/sitemap.xml.ts, fetches the sitemap our CMS builds and writes it into the build. The CMS knows what Astro can't see from built files: which entries are published, which are translations, and when each changed. Each entry carries its language alternates and its cover image.

Google says it uses <lastmod> only if it's "consistently and verifiably" accurate, and that it should reflect the last significant update. Its 2023 note on lastmod is blunter: if the dates don't match reality, "eventually we're not going to believe you anymore."

Our crawl on 17 October found every one of our 106 articles with a lastmod earlier than its own publish date, 38 of them by a day or more. /en/blog/ga4-vs-posthog/ said 14 October and was published on 15 October at 05:17 UTC. The cause was a date-only "reviewed" field that won over later publishes. The fix, in the CMS, takes the latest of the three dates:

const lastChange = (...values: unknown[]) => {
  const dates = values.map(iso).filter((v): v is string => !!v && !Number.isNaN(Date.parse(v)));
  return dates.length ? dates.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a)) : null;
};
// lastmod: lastChange(data.reviewed_at, data.updatedAt, data.publishedAt)

On 21 October the sitemap shows 2026-10-15T05:17:31.620Z for that article, the same time as its article:published_time. The trade-off we accepted: an edit to a typo also moves updatedAt, which is less precise than Google's "significant update", but a date that runs a little late is a smaller error than one that predates the page.

If you use @astrojs/sitemap, don't copy the docs' serialize() example literally. It sets item.lastmod = new Date().toISOString(), which stamps every URL with the build time, the exact pattern Google says it learns to ignore. Feed it the content's own date instead:

// astro.config.mjs
import sitemap from "@astrojs/sitemap";
import lastModified from "./src/data/last-modified.json"; // { "/blog/post/": "2026-10-15T05:17:31Z" }

export default defineConfig({
  site: "https://example.com",
  integrations: [
    sitemap({
      serialize(item) {
        const date = lastModified[new URL(item.url).pathname];
        if (date) item.lastmod = date; // the page's real change, never the build time
        return item;
      },
    }),
  ],
});

Two other limits from the integration's docs: it can't list dynamic routes rendered on demand, and Google ignores the changefreq and priority values it can write.

Images: the fix that phones needed

Service pages on dardo.studio open with artwork that animates from a card into the page cover. To keep that animation smooth, the card and the cover shared one file, and that file was the full 1,672-pixel original. Phones downloaded that full file, up to 675 KB, and it was their Largest Contentful Paint element. Lighthouse reported 538 KiB of savings from properly sized images on /en/services/mcp-server-development/.

The fix keeps the shared file but offers three widths, with the same sizes on the card and the cover so both pick the same candidate:

// Artwork: the same few widths and the same sizes everywhere, so the card and the
// cover it animates into always choose the same file.
const widths = isArtwork
  ? [...new Set([960, 1280, source.width].filter((w) => w <= source.width))]
  : [...new Set([400, 640, 800, 1024, 1280, 1440, 1600, 1920, 2560, source.width].filter((w) => w <= source.width))];
<Image src={src} alt={alt} widths={widths} sizes={isArtwork ? "100vw" : sizes}
  format="webp" quality={isArtwork ? 80 : 88}
  loading={priority ? "eager" : "lazy"} fetchpriority={priority ? "high" : "auto"} />

The live page now offers 960, 1280 and 1672 pixel versions in its srcset (checked on 21 October). Astro's <Image /> also writes width and height from the file, which prevents layout shift, and its priority option sets eager loading and fetchpriority="high" for you. Use it on one or two images per page: web.dev's LCP guide warns that raising the priority of more than that stops helping, and that a smaller file only shortens LCP when download time, not render delay, was the bottleneck. Check field data after a change like this; our Core Web Vitals playbook explains how.

Trailing slashes: a 307 is not a 301

Astro's trailingSlash setting sounds like it controls your URLs. The configuration reference says otherwise: it applies to the dev server and on-demand pages, and "trailing slashes on prerendered pages are handled by the hosting platform, and may not respect your chosen configuration."

On Cloudflare Workers, the platform's default HTML handling serves folder/index.html at /folder/ and answers /folder with a 307. Google treats 307 as temporary: it follows it but doesn't use it as a signal that the target is canonical. 301 and 308 are the permanent ones. Our crawl caught /en/contact answering 307. The Worker now rewrites exactly that case:

// The assets layer answers /x or /x/index.html with a temporary 307 to /x/.
// That's the page's permanent address, so search engines get a 301.
export function permanentSlashRedirect(response, url) {
  if (response.status !== 307) return response;
  const location = response.headers.get("Location");
  if (!location) return response;
  const to = new URL(location, url);
  const page = (path) => path.replace(/(\/index)?\.html$/, "/").replace(/\/+$/, "");
  if (to.origin !== url.origin || page(to.pathname) !== page(url.pathname)) return response;
  return new Response(null, { status: 301, headers: response.headers });
}

Any other redirect passes through untouched. curl -I https://dardo.studio/en/contact returned a 301 to /en/contact/ on 21 October. Check your own host the same way: send a request without the slash and read the status code, not just the final page.

The same reference has a trap for moved pages. Astro's redirects setting produces a <meta http-equiv="refresh"> page on a static site without an adapter, with no status code at all; with an adapter, GET requests get a 301. Google reads an instant meta refresh as permanent, but ranks server-side redirects as the most likely to be interpreted correctly, so put redirects that matter in your host or server. Our migration SEO checklist covers the full redirect map.

Astro will happily build a page that links to a page you never built. We added an integration that runs on the astro:build:done hook, after the Markdown copies and redirects are written:

export function internalLinksCheck() {
  return {
    name: "internal-links-check",
    hooks: {
      "astro:build:done": ({ dir, logger }) => {
        const { broken, redirected } = internalLinks(fileURLToPath(dir));
        if (broken.length) throw new Error(`${broken.length} internal link target(s) don't reach a page:\n${broken.join("\n")}`);
        if (redirected.length) logger.warn(`${redirected.length} internal link target(s) reach their page through a redirect`);
      },
    },
  };
}

It reads every <a href> and <link href> in the built pages, plus the site's own absolute URLs in llms.txt, the Markdown copies, the sitemap and the RSS feeds. A link to a page that wasn't built, or to content we retired with a 410, fails the build; a link that only works through a redirect is logged, so we can point it at the final URL.

We wrote it on 12 October, after finding case-study links built from a URL pattern instead of each language's real slug: 21 of those addresses, in seven languages, answered 404. It also has a cost we learned on 17 October, when we published 86 articles in one day: a link to an article not yet published fails the build, and a failed build blocks every deploy after it, code included. We held those links back and restored them once their targets were live. Two false positives also failed builds that day (an address written inside a Markdown code span, read with its closing backtick, and an example URL in a retired language that our Worker redirects), and we fixed the check rather than switching it off.

The same crawl drew the site's link graph. Six tool pages were linked from only three pages each, and two articles had no links from any other article. Two changes in the Astro code fixed that:

  • The blog index lists the newest six articles, then every article under its topic, with ItemList data. Nothing is reachable only through pagination.
  • Related articles are chosen for the whole blog at once, not per article. The editor's picks come first, then articles that link back, then those sharing a service, then the same topic, and among equals the article with the fewest links so far wins. Links spread instead of piling onto the newest posts.

Markdown copies and llms.txt for AI agents

Every indexable page on dardo.studio has a Markdown copy written after the build, announced in the head with <link rel="alternate" type="text/markdown">. Since 17 October each page also points to its language's llms.txt with rel="describedby". The Worker serves the copy when a client asks for text/markdown in its Accept header, adds Vary: Accept, and sends a Link header with rel="canonical" pointing at the HTML page, so search engines credit the page, not the copy. We checked those headers on 21 October.

None of this replaces the HTML. Our llms.txt guide covers who actually reads the file, and what an agent-ready website needs covers the rest of the layer. Our AI visibility checker reads any site's robots.txt, llms.txt, JSON-LD and sitemap and reports what's missing.

View transitions: what they change for SEO

Astro offers two kinds of page transitions, and the view transitions guide is clear about the difference. Browser-native cross-document transitions "don't alter the core functionality of a multi-page application" and add no JavaScript. Astro's <ClientRouter /> turns the site into client-side navigation, with shared state and persistent elements, and the drawback that bundled scripts run only once, so menus, analytics and widgets must re-initialize after each navigation.

For crawlers, neither changes much: Googlebot requests each URL on its own and gets the full pre-rendered HTML. The risks are on your side: page views that analytics never records, a menu that stops working on the second page, scripts that run twice. dardo.studio uses a small page-transition library with Astro's head-swapping functions, and every URL remains a complete HTML document. Astro itself says the router will "increasingly become unnecessary" as browsers catch up. Start with native transitions and add the router only for a feature you need.

An Astro SEO checklist

  1. Every page renders one head component with a unique title, a description, a robots tag and an absolute, self-referencing canonical.
  2. trailingSlash and build.format match the URLs your host actually serves, and a request for the other form answers 301 or 308, not 307.
  3. hreflang sets come from your content's translations, list every version including the page itself, and include x-default.
  4. One JSON-LD graph per page, linked by @id, with < escaped and dates as full date-times.
  5. The sitemap lists only indexable pages, with alternates and a lastmod that is never earlier than the page's last real change, and never the build time.
  6. Above-the-fold images use <Image /> with widths and sizes, and only one or two get priority.
  7. Redirects live on the host or server with permanent status codes, not meta refresh.
  8. A build step fails on internal links to pages that weren't built.
  9. Hubs link to every item they collect, so no page depends on pagination or search to be found.
  10. If you add client-side navigation, analytics and scripts are re-initialized on every page change.

What to do next

Run your site through the AI visibility checker to see its structured data and sitemap as a crawler does, and use the redirect map generator before any URL change. If you'd rather have someone else set this up, it's part of our Astro development and technical SEO work; for speed problems specifically, see website speed optimization.

Common questions

Is Astro good for SEO?

Yes, for content sites. Astro pre-renders pages to complete HTML by default, which Google recommends because not every crawler runs JavaScript. You still have to write titles, canonicals, hreflang, structured data and a sitemap, and set up permanent redirects on your host.

Does Astro need an SEO plugin?

No. The only official SEO integration is @astrojs/sitemap. Packages like astro-seo and astro-seo-schema save typing meta and JSON-LD tags, but a small head component of your own does the same job and can read canonicals, translations and dates from your content.

How do I add JSON-LD schema in Astro?

Build the object in the component's frontmatter, serialize it with JSON.stringify, replace every < with \u003c, and render it with set:html inside a script tag of type application/ld+json. One @graph per page whose nodes reference each other by @id keeps the organization and website described once.

Should I use @astrojs/sitemap?

It is fine for most static Astro sites. Give each URL its content's real change date through serialize(), not the build time, and know that it can't list routes rendered on demand and maps languages by path prefix, which fails when slugs are translated.

Do Astro view transitions hurt SEO?

Not directly: crawlers request each URL separately and get the full HTML. The risks are analytics that miss page views and scripts that stop working after client-side navigation with the ClientRouter. Native cross-document view transitions add no JavaScript and avoid both.

How do I handle trailing slashes in Astro?

Choose one format, set trailingSlash and build.format to match it, and check what your host does with the other form. Astro's setting only covers the dev server and on-demand pages; a host that answers 307 should be configured or wrapped to answer 301 or 308.

Sources & further reading

Sources behind this page, with further detail from the original publishers.