One helper builds every absolute URL in the app: the canonical tag, every entry in the sitemap and
feeds, and every `@id` inside a JSON-LD block. That's deliberate. An absolute URL built two
different ways in two different places is how the JSON-LD graph stops joining up and a rich result
silently gets dropped from search.

## Canonical URLs

Every page renders exactly one `<link rel="canonical">`, built from the request's path, not its
full URL. That distinction matters: tracking and campaign query strings never become part of the
canonical, so `/blog/some-post?utm_source=newsletter` and `/blog/some-post` don't compete against
each other for the same page's ranking. A page can override the default by setting `content_for
:canonical`; `/privacy` uses this to point at `/terms`, since they render the same content and
shouldn't count as two separate pages.

## Open Graph and Twitter cards

The layout renders `og:title`, `og:description`, `og:image`, and their Twitter equivalents on every
page, falling back to the site-wide description and default share image when a page sets nothing
itself. A page opts into something more specific with `content_for :meta_description`, `content_for
:og_type` (posts and docs pages set this to `"article"`), and `content_for :og_image`. See
[OG Images](/docs/content-seo/og-images) for how the per-page share images themselves get
generated.

## Structured data (JSON-LD)

Every builder lives in one helper and returns a plain Ruby hash for a schema.org type:
`Organization` and `WebSite` on every page, `SoftwareApplication` (with a live price, read from the
same place checkout reads it, so they can't disagree) on the home page, `BlogPosting` and
`BreadcrumbList` on a blog post, `TechArticle` and `BreadcrumbList` on a docs page like this one,
and `FAQPage` on the support page. Nodes carry a stable `@id`, so the publisher of a post is
recognized as the *same* entity as the site's own `Organization` node, rather than a disconnected
duplicate.

## What's automatic versus what you set per page

Automatic, for every blog post and every docs page: the canonical tag, the sitemap entry with a
`lastmod`, an entry in both feeds (blog only) or in `llms.txt`/`llms-full.txt` (both), and a raw
markdown alternate at `<path>.md`. What you set per page: the title, the description, and
optionally an image override; everything downstream of those three fields is generated.

## Two things that fail completely silently

Both are already handled correctly in the kit, and both are worth understanding before you touch
either config file.

**`allow_browser`** answers a browser it recognizes as outdated with a bare 406, no page, no
explanation. The blog and every SEO endpoint are exempt, listed in
`ApplicationController::PUBLIC_CONTENT_PREFIXES`, because they're static prose that needs no
JavaScript at all, and turning away a reader on an old browser costs reach for nothing.

**Rack::Attack's per-IP throttle** would 429 a search or AI crawler mid-sweep of the blog or the
docs site, and a crawler reads a 429 as "this site is unhealthy, visit less often." The same public
paths are exempt in `config/initializers/rack_attack.rb`. The two exemption lists have to be kept
in sync by hand; a path added to one without the other degrades silently; there's no error, no log
line, just slowly declining crawl traffic.

## Verifying it

```bash
bin/dev
curl -sI http://localhost:3000/sitemap.xml
curl -sI http://localhost:3000/llms.txt
```

Validate structured data with Google's Rich Results Test against a live URL. In production, confirm
a crawler can actually reach the site end to end, which tests your CDN, `allow_browser`, and the
throttle in one request:

```bash
curl -sI -A "OAI-SearchBot" https://yourdomain.com/blog | head -1   # expect 200
```

## Next

See how to make sure AI assistants not only can reach your content, but actually cite it:
[Being Cited by AI Assistants](/docs/content-seo/being-cited-by-ai-assistants).
