SEO for Solos
5.xcompiler

Astro SEO

The lowest-JavaScript starting point of any framework here. Nothing hydrates unless you ask, which means most of the performance group passes before you write any code.

What you start with

  • +Zero client JavaScript by default
  • +Static output by default, with server rendering opt-in per route
  • +Built-in sitemap and RSS integrations
  • +Content collections with typed frontmatter validated by Zod

What it makes harder

  • !trailingSlash defaults to 'ignore', which serves both forms with a 200 and duplicates every page
  • !A stray client: directive reintroduces hydration you had avoided by default
  • !set:html does not escape, so a JSON-LD block needs the < escape applied by hand

Recipes

Each one is real Astro code, not the same snippet with the imports swapped.

One trailing-slash policy, set explicitly

`/about` and `/about/` are different URLs to a crawler. Serving both with 200 creates a duplicate for every page on the site. Check found-trailing-slash-policy

astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  site: "https://www.example.com",
  // 'ignore' is the default and it serves /about and /about/ with a 200,
  // duplicating every page on the site. Always set this explicitly.
  trailingSlash: "never",
  build: { format: "file" },
});

Canonical from Astro.url and the site config

A relative canonical resolves differently depending on the requested URL, which is exactly the ambiguity a canonical exists to remove. Check meta-canonical-absolute

src/components/BaseHead.astro
---
interface Props { title: string; description: string; }
const { title, description } = Astro.props;
// Astro.site comes from the config, so the origin is defined once.
const canonical = new URL(Astro.url.pathname.replace(/\/$/, ""), Astro.site).href;
---
<title>{title} | Acme</title>
<meta content={description} name="description" />
<link href={canonical} rel="canonical" />
<meta content={canonical} property="og:url" />
<meta content="summary_large_image" name="twitter:card" />

Entity gate through getStaticPaths

An ungated dynamic route generates a page for any slug anyone types, including slugs an attacker generates. That is how a site accidentally publishes ten thousand thin pages. Check pseo-entity-gate

src/pages/glossary/[term].astro
---
import { GLOSSARY_TERMS } from "../../data/glossary-terms";

export function getStaticPaths() {
  return GLOSSARY_TERMS.map((term) => ({
    params: { term: term.slug },
    props: { entry: term },
  }));
}

const { entry } = Astro.props;
---
<h1>{entry.name}</h1>

JSON-LD with set:html

Multiple disconnected script tags describe several unrelated things. One graph describes one page, and lets nodes reference each other instead of repeating themselves. Check schema-single-script

src/components/JsonLd.astro
---
interface Props { data: unknown; }
const { data } = Astro.props;
// set:html does NOT escape. Without this replace, a string containing
// </script> terminates the block early.
const json = JSON.stringify(data).replace(/</g, "\\u003c");
---
<script is:inline set:html={json} type="application/ld+json" />

robots.txt as a static endpoint

A hand-maintained robots.txt drifts from the routes it is supposed to describe. Generating it means adding a private route section cannot forget to disallow it. Check crawl-robots-generated

src/pages/robots.txt.ts
import type { APIRoute } from "astro";
import { robotsTxt, RECOMMENDED_POLICY } from "../../kit/seo/robots";

export const GET: APIRoute = ({ site }) =>
  new Response(
    robotsTxt({
      ...RECOMMENDED_POLICY,
      disallowPaths: ["/api/", "/account/"],
      sitemaps: [new URL("sitemap-index.xml", site).href],
    }),
    { headers: { "Content-Type": "text/plain; charset=utf-8" } },
  );

Hydration only where it is needed

An exit-intent modal that ships in the main bundle costs every visitor the bytes for a component most of them never see. Check perf-code-split-noncritical

src/pages/index.astro
---
import Hero from "../components/Hero.astro";
import ExitIntent from "../components/ExitIntent.jsx";
---
<Hero />
<!-- client:idle defers hydration until the browser is idle. client:visible
     would wait for the element to enter the viewport, which is wrong for a
     modal that has no visible trigger. -->
<ExitIntent client:idle />

The same checks in other frameworks

Questions

What does Astro give you before you write any SEO code?

Astro starts with: Zero client JavaScript by default; Static output by default, with server rendering opt-in per route; Built-in sitemap and RSS integrations; Content collections with typed frontmatter validated by Zod.

What does Astro make harder?

trailingSlash defaults to 'ignore', which serves both forms with a 200 and duplicates every page This is specific to Astro rather than a general SEO problem.

How many SEO recipes does SEO for Solos publish for Astro?

SEO for Solos publishes 6 Astro recipes, each with real Astro code and a note on what differs from the same check in other frameworks.