Docs · Content & SEO

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.