SEO and Structured Data
Canonical URLs, Open Graph, JSON-LD, and what's automatic versus what you set per page.
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 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
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:
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.