@bettercms-ai/mcp 0.83.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,357 +1,357 @@
1
- ---
2
- title: "SEO and AEO"
3
- description: "Native page and entry meta, carrying over a source site's meta, redirects, Markdown twins, route audit, structured data by content type, sitemap, robots and AI crawlers, AEO, validators, a generated OG image route and the SEO/AEO gate."
4
- ---
5
-
6
- # SEO and AEO
7
-
8
- **Outcome.** Every publishable route sends correct technical signals and carries useful,
9
- attributable content. Keep an implemented change separate from a search-engine outcome, which
10
- no one can guarantee.
11
-
12
- Doctrine: SEO is native and never modelled (§5b of `bettercms://playbook/schema`); flow:
13
- `seo_optimize` prompt.
14
-
15
- ## Contents
16
-
17
- - 1. Where each value lives (carry over the source's meta, Markdown twins)
18
- - 2. Redirects and URLs
19
- - 3. Route-level audit
20
- - 4. Structured data (content to schema.org type, rules)
21
- - 5. Sitemap, robots, llms.txt (AI crawlers)
22
- - 6. AEO and content quality
23
- - 7. Publish and verify (validators, generated OG image)
24
- - SEO and AEO gate
25
-
26
- ## 1. Where each value lives
27
-
28
- | Value | Writer | Notes |
29
- |---|---|---|
30
- | Page title, description, `schemaType`, `schema`, noindex, canonical, OG, Twitter | `update_page` | Merges into what is stored: an absent key keeps its value, `null` or `""` clears it |
31
- | Entry meta (title, description, noindex, canonical, OG, Twitter, `schemaType`, `schema`) | `update_content_entry` with `meta` | Same merge rule |
32
- | Site defaults (`seoDefaults`: title, description, OG image, Twitter handle, `og`, `twitter`, `collectionSchemaTypes`), site JSON-LD (`siteSchema`), `robotsConfig`, `sitemapConfig`, `rssConfig`, `indexnowConfig`, `searchConfig` | `update_seo`, read with `get_seo` | Each key REPLACES its whole object, so read and send it complete. `collectionSchemaTypes` maps a collection's model id to its entries' schema.org type; a key the plan lacks answers 403 `FEATURE_LOCKED` |
33
- | Suggestions | `generate_seo_meta` | Returns a suggestion only; apply it with the writers above |
34
- | Audit | `list_seo_issues` | Computed live from published pages; nothing is stored. Also reports accessibility issues as `a11y.` codes (image alt text, heading order, generic link text, video transcripts), each naming the page and its block ids |
35
- | Redirects | `list_redirects`, `create_redirect`, `import_redirects`, `update_redirect`, `delete_redirect` | 301, 302, 307 or 308; 301 when omitted. 410 takes no destination: the path answers Gone and leaves the sitemap. For a list of rules use `import_redirects` (rows, CSV, a Netlify `_redirects` file or a vercel.json) with `dryRun: true` first; each row comes back created, existing or skipped with its reason |
36
-
37
- Never create `seoTitle`-style fields on a model, page or component: the renderer, the sitemap,
38
- `llms.txt` and `list_seo_issues` read the native values only. Self-hosted sites own their
39
- `<head>`, sitemap and robots; the platform renderer emits them only for hosted sites, so confirm
40
- the frontend emits the native values in its HTML ([next.md](next.md) covers `buildMetadata`).
41
- There is no hreflang field: translations are native rows (`locale`, `translationOf`). The hosted
42
- sitemap emits the hreflang alternates from them; a frontend emits the `<head>` links from each
43
- entry's delivered `translations` ([localization.md](localization.md)).
44
-
45
- ### Carry over the source's meta
46
-
47
- On an import or conversion, read every source page's `<head>` before writing. Copy published
48
- values as they are; rewriting copy is a separate, reported recommendation.
49
-
50
- | Source | Native writer and key | Rule |
51
- |---|---|---|
52
- | `<title>` | `metaTitle` (`update_page`; `meta.metaTitle` on an entry) | Copy verbatim. Report one over 60 characters; `list_seo_issues` flags missing, over 60 and duplicates |
53
- | `<meta name="description">` | `metaDescription` | Copy verbatim. Aim for 120–160 characters; the audit flags only a missing one and one over 160. When the source has none, take a `generate_seo_meta` suggestion and list it for review |
54
- | `og:title`, `og:description`, `og:image`, `og:type` | `og.title`, `og.description`, `og.image`, `og.type` | Write a key only when it differs from the meta title, the description or the site default. Upload the image first and store its media URL |
55
- | `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` | `twitter.card`, `twitter.title`, `twitter.description`, `twitter.image` | Same rule as OG |
56
- | `twitter:site` | `update_seo` `seoDefaults.twitterHandle` | Once per site. A BetterCMS-built import fills it, the card and `og:type` from the home page's head where the project holds none |
57
- | `keywords`, `author`, `theme-color`, a site verification tag | nothing | No native key stores them. The binding report's `elementGaps` lists each by name (`HEAD_META_UNMAPPED`); report them |
58
- | A site-wide share image | `update_seo` `seoDefaults.ogImage` | Then pages inherit it and `og.image` stays empty |
59
- | Canonical pointing at the page itself | nothing | A hosted page gets its own canonical |
60
- | Canonical pointing at another URL | `canonical` | Keep it: someone consolidated those URLs on purpose |
61
- | `<meta name="robots" content="noindex">` | `noindex: true` | Also drops the route from the sitemap, `llms.txt`, its Markdown twin and site search |
62
- | `nofollow`, `noarchive`, `max-snippet` and other robots directives | nothing | No native key stores them. Report each page that used one |
63
- | `<link rel="alternate" hreflang>` | nothing on the page; one row per language with `locale` and `translationOf` | Pair the languages by URL and structure, never by matching text ([localization.md](localization.md)) |
64
- | JSON-LD on a page | nothing | Read it as evidence and compare it with what BetterCMS emits (§4) |
65
- | Organization JSON-LD on the home page | `update_seo` `siteSchema` | Keep name, URL, logo and `sameAs`; drop any value the site does not show |
66
- | robots.txt | `update_seo` `robotsConfig` | Stay on `auto` unless the source has rules worth keeping, then `custom` with that text (§5) |
67
- | sitemap.xml | `update_seo` `sitemapConfig` | Stay on `auto`. URLs the CMS does not own (a docs path, a static app) go in `extraUrls` |
68
- | RSS or Atom feed | `update_seo` `rssConfig` `{ enabled: true }` | Item links come from the collection's `urlPattern` |
69
-
70
- ### Markdown twins
71
-
72
- Every published page and entry is also served as Markdown for answer engines, at its own URL plus
73
- `.md` (the home page at `/index.md`; an entry at its parent page's path, then `/<slug>.md`).
74
-
75
- - The twin is generated from the content, per request. Nothing to model or keep in sync.
76
- - A noindex page or entry has no twin, and is left out of `llms.txt`.
77
- - To replace one twin, store a Markdown file at the same path in the dashboard (Pages →
78
- Markdown). Only a published file serves; deleting it restores the generated twin. A second file
79
- at the same path is refused (409, code markdown_path_taken).
80
- - `llms.txt`, `llms-full.txt` and which pages serve a twin are switched there too. Those writes
81
- need the SEO permission, and no MCP tool writes them.
82
-
83
- ## 2. Redirects and URLs
84
-
85
- Keep every URL that ranks or is linked to. Change a path only when BetterCMS cannot express it.
86
-
87
- 1. Build a URL map first: each source URL (from the sitemap and a crawl) beside its new path.
88
- 2. Every row whose path changes gets one `create_redirect` (301) aimed at the FINAL URL, never at
89
- an intermediate one. Chains are collapsed for you; a loop answers 422 and a source path that
90
- already has a rule answers 409.
91
- 3. Import the source's own rules as well: a `_redirects` file, `vercel.json`, `netlify.toml`,
92
- `.htaccess` or a redirect plugin's export.
93
- 4. Never send a batch of removed pages to the home page. Redirect each to its closest equivalent,
94
- or retire it with a 410.
95
- 5. 302 or 307 is for a temporary move, 308 when the request method must survive.
96
- 6. A page gone for good with no equivalent takes `redirectType: "410"` and no destination. List
97
- those in the report.
98
-
99
- | Case | Do |
100
- |---|---|
101
- | Slug shape | Lowercase letters, digits and hyphens. Strip `.html`, a trailing slash and capitals, each with a 301. Keep the source's words: a slug is not copy to improve |
102
- | Collection URLs | Set `urlPattern` (`/blog/:slug`): a fixed prefix and `:slug` exactly once |
103
- | Nested (`/:category/:slug`), dated (`/2024/05/post`) or query-string (`?p=123`) URLs | The pattern cannot say these. Map each to the flat pattern and add one 301 per old URL |
104
- | Two sources want one slug | The one with more links or traffic keeps it. Give the other a suffix from its own words (`-guide`, the city), add its 301 and report it. A taken model slug answers 409 (code slug_taken) |
105
- | A page or entry slug changed in BetterCMS | The 301 is written by the same save (an entry needs its collection's `urlPattern`). Do not add a second one |
106
- | A URL folder renamed or moved | 301s are written for every page under it. Do not add them again |
107
- | A closed job, a past event, a sold-out product | Keep the URL and mark the item closed in its content. Do not delete it |
108
-
109
- ## 3. Route-level audit
110
-
111
- Read the route inventory, page and entry records, site defaults, the rendered `<head>`, robots,
112
- sitemap, structured data and redirects. For each indexable route check:
113
-
114
- - HTTP status, canonical, title, description, the main heading, language, breadcrumbs,
115
- internal links, image alt text and the social preview.
116
- - Page type and structured-data eligibility; schema values must match visible content.
117
- - Duplicate or near-duplicate routes, pagination, query parameters, redirects, trailing slash.
118
- - Robots and noindex conflicts, staging or private pages that got indexed, sitemap inclusion.
119
- - Client-rendered content: does the initial HTML carry the key content?
120
-
121
- A CMS field holding the right title does not prove the public route emits it. Fetch the HTML.
122
-
123
- ## 4. Structured data
124
-
125
- `list_seo_issues` reports a pasted `schema` that lacks its type's required properties as
126
- `schema.invalid`, naming the type and what is missing.
127
-
128
- Choose in this order, and stop at the first that expresses the page:
129
-
130
- 1. **Automatic.** With no `schemaType` and no `schema`, JSON-LD is built from the content at
131
- render time and follows every edit: WebSite on the home page (plus Organization when the site
132
- has a name and a logo, or `siteSchema` when one is stored), Blog on a list of posts,
133
- CollectionPage on any other list, BlogPosting on a post entry, Product on an entry of a
134
- collection with a price or SKU, WebPage everywhere else. FAQPage is never automatic. A hosted
135
- page or entry also gets a BreadcrumbList beside its main type: Home, each ancestor path that
136
- is itself a published page (a folder with no page is skipped), then the document.
137
- 2. **`schemaType`.** One of nineteen: WebPage, AboutPage, ContactPage, CollectionPage, Blog,
138
- BlogPosting, Article, Product, FAQPage, Event, Organization, LocalBusiness, JobPosting, Course,
139
- Recipe, HowTo, Service, Person, VideoObject. Its properties are filled from the document's own
140
- fields by key (a job's `company`, a recipe's `ingredients`), and a property the content lacks
141
- is left out. A type missing what it cannot do without (a JobPosting with no company or posting
142
- date, a Recipe with no image, a HowTo with no steps) is not emitted: the document keeps its
143
- automatic type until the field is filled. Clear it with `"auto"`
144
- on `update_page` or `null` in an entry's `meta`. For a whole collection, set
145
- `seoDefaults.collectionSchemaTypes` once instead of typing every entry.
146
- 3. **Pasted `schema`.** Only when neither of the above can express the type AND every value in it
147
- is visible on the page. It wins over both and does not follow content edits, so it goes stale.
148
-
149
- JSON-LD found on the source site is evidence, not content. Use it to fill fields (event dates,
150
- opening hours, prices) and to check that the emitted type and key values match. Do not paste it
151
- back. Never add a second copy through custom head code.
152
-
153
- ### Content to schema.org type
154
-
155
- | Source content | Type | How in BetterCMS |
156
- |---|---|---|
157
- | The whole site | Organization, WebSite | Automatic on the home page; `siteSchema` to add `sameAs` or replace it |
158
- | Blog or journal post | BlogPosting, or Article | Automatic for a collection with a body that is named for posts or has a date and an author; else `collectionSchemaTypes` |
159
- | List of posts, any other list | Blog, CollectionPage | Automatic |
160
- | Product | Product with an Offer | Automatic when the model has a price or SKU; else `schemaType` |
161
- | FAQ: visible questions with answers | FAQPage | `schemaType: "FAQPage"`. It reads the first repeater whose rows hold a question and an answer |
162
- | Event | Event | `schemaType` (name, start, end, location read from the fields) |
163
- | Company, store, restaurant, office | Organization, LocalBusiness | `schemaType`; a subtype such as Restaurant, a `PostalAddress`, geo or opening hours need pasted `schema` |
164
- | About or contact page | AboutPage, ContactPage | `schemaType` |
165
- | Breadcrumb trail | BreadcrumbList | Automatic on hosted pages (ancestors that have a page). Paste `schema` only to match a trail the page shows differently |
166
- | News, recipe, job, video, course, podcast episode, how-to, author profile | NewsArticle, Recipe, JobPosting, VideoObject, Course, PodcastEpisode, HowTo, ProfilePage | Pasted `schema`, visible values only |
167
- | Product with on-page reviews | AggregateRating, Review | Pasted `schema`, with the average and count the page displays |
168
-
169
- ### Rules
170
-
171
- - Every value must be on the page. No invented rating, review count, price, availability, salary,
172
- author or date. What the page does not show is left out.
173
- - One FAQPage per URL and one main entity per page. Two FAQ sections on one page still make one
174
- FAQPage. An accordion of features is not a FAQ.
175
- - `AggregateRating` needs a product or another reviewable subject. Never on the home page about
176
- the business itself, and never typed by hand.
177
- - Awards go in `award`; they are not certifications.
178
- - Dates are ISO 8601 with a time zone offset.
179
- - Cart, checkout, 404 and legal pages stay on Automatic (plain WebPage). There is no off switch;
180
- never give them a richer type.
181
- - Pasted JSON-LD is stored as given and is not checked for required properties. Check them
182
- yourself before saving:
183
-
184
- | Type | Required |
185
- |---|---|
186
- | Article, BlogPosting | `headline`, `image` |
187
- | Product | `name` plus one of `offers`, `review`, `aggregateRating` |
188
- | Recipe | `name`, `image` |
189
- | FAQPage | `mainEntity`, each with `name` and `acceptedAnswer.text` |
190
- | BreadcrumbList | `itemListElement`, each with `position` and `name` |
191
- | Organization | `name`, `url` |
192
- | LocalBusiness | `name`, `address` |
193
- | Event | `name`, `startDate`, `location` |
194
-
195
- Valid markup does not guarantee a rich result: [Google structured-data guidelines](https://developers.google.com/search/docs/appearance/structured-data/sd-policies).
196
-
197
- ## 5. Sitemap, robots, llms.txt
198
-
199
- - The hosted sitemap leaves out noindex pages, and the generated robots.txt points at the
200
- sitemap. Confirm drafts, private pages and noindex pages are absent from the live file.
201
- - A translated page or entry carries `<xhtml:link rel="alternate" hreflang>` for each listed
202
- sibling (its `locale`, else the project's `localeConfig.defaultLanguage`) plus `x-default` for
203
- the source row. A noindex or unpublished sibling is left out of the links as well.
204
- - `sitemapConfig` sets it `auto` or `custom`, the default changefreq and priority and `extraUrls`;
205
- `robotsConfig` is `auto` (with `disallow`: AI crawler names such as `GPTBot` or `ClaudeBot` to block) or `custom` text, which wins when set; `indexnowConfig` { enabled, excludePatterns } pings
206
- IndexNow on publish; `searchConfig` { enabled, placeholder, aiEnabled } is site search, which
207
- `search_content` queries (published content only).
208
- - `refresh_search_index` costs nothing: `action: 'indexnow'` submits `urls` (this site's own, up
209
- to 1000) or, without them, every change since the last submission; `action: 'reindex'` rebuilds
210
- site search after bulk changes. A 503 means the index is unreachable: retry later.
211
- - `llms.txt` lists one link per published item. Maintain it for its intended readers, but do not
212
- sell it as a ranking requirement: Google's [guidance for AI features](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)
213
- says ordinary SEO fundamentals are the base.
214
- - `set_site_file` stores a hand-written `llms.txt` or `llms-full.txt` (plain text, up to 5 MB).
215
- Give `llms.txt` a one-line site summary taken from the home page's description or About copy.
216
- - Check each redirect's real status and target, and detect loops.
217
-
218
- ### AI crawlers
219
-
220
- `robotsConfig` is `{ mode: "auto" }` or `{ mode: "custom", custom: "<robots.txt text>" }` (up to
221
- 10,000 characters). `robotsConfig.disallow` is the per-bot switch: a list of names from GPTBot,
222
- OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-Web, anthropic-ai, PerplexityBot, Google-Extended,
223
- CCBot, Bytespider, Applebot-Extended, Amazonbot and Meta-ExternalAgent. Each named bot is
224
- disallowed from the whole site in auto mode. In custom mode the custom text is served as written
225
- and `disallow` is not applied; any other bot or a path rule is a line of custom text.
226
-
227
- - Carry the source's AI-bot rules over exactly. Changing the owner's crawler policy is not part of
228
- a conversion. No rules in the source: stay on `auto` and say so in the report.
229
- - Custom text replaces the generated file, so include the `Sitemap:` line yourself.
230
- - Blocking `Google-Extended` or `Applebot-Extended` leaves Google Search and Siri untouched.
231
- Blocking an answer or search bot (`OAI-SearchBot`, `ChatGPT-User`, `PerplexityBot`,
232
- `DuckAssistBot`) removes the site from that product's answers. Say so before the owner asks
233
- for it.
234
- - Other agents to look for in a source file: `GPTBot`, `ClaudeBot`, `Claude-Web`, `anthropic-ai`,
235
- `Meta-ExternalAgent`, `FacebookBot`, `Amazonbot`, `cohere-ai`, `CCBot`, `Bytespider`.
236
-
237
- ```text
238
- User-agent: *
239
- Allow: /
240
-
241
- User-agent: CCBot
242
- Disallow: /
243
-
244
- Sitemap: https://caldera.com/sitemap.xml
245
- ```
246
-
247
- ## 6. AEO and content quality
248
-
249
- Set these up while converting, without asking:
250
-
251
- | Do | How |
252
- |---|---|
253
- | Facts as text | Specs, prices, menus, opening hours and chart data become fields or a real table, never an image of them |
254
- | Answer first | A real question on the page becomes a FAQ row: the question as asked, the answer in the first one or two sentences, detail after |
255
- | One name per thing | A product, plan, person or brand is one entry that other content references, so a rename reaches every page and feed |
256
- | Dated facts | A statistic or comparison keeps its date and source in fields beside the number |
257
- | Authors as entries | An author collection with name, bio and photo, referenced from each post |
258
- | Transcripts | Audio and video carry one, because crawlers do not watch video. It lives on the asset (`update_media` `transcript`); never invent one ([accessibility.md](accessibility.md)) |
259
- | Twins and `llms.txt` | Leave them on unless the owner turned them off |
260
-
261
- Improve the page for people first: clear questions and answers where the topic warrants them,
262
- descriptive headings, first-hand evidence, dates and named authors where real, text alongside
263
- media, consistent internal links and accurate structured data. No "AI-only" pages, artificial
264
- snippets or unverifiable claims.
265
-
266
- Report concrete findings ("canonical points to a 404", "OG image missing", "FAQ schema does not
267
- match the visible answers"), never an untraceable score.
268
-
269
- ## 7. Publish and verify
270
-
271
- After an authorized change ([publishing.md](publishing.md)): inspect the rendered `<head>` and JSON-LD,
272
- route status, sitemap and robots output, and redirects. Use Search Console or similar only to
273
- report observed behaviour after launch, never to predict indexing.
274
-
275
- Validators, run against the live URL (they read the public HTML, never the CMS):
276
-
277
- | Checks | Tool |
278
- |---|---|
279
- | Open Graph tags as Facebook reads them; refreshes its cache | [Sharing Debugger](https://developers.facebook.com/tools/debug/) |
280
- | LinkedIn card | [Post Inspector](https://www.linkedin.com/post-inspector/) |
281
- | Any OG card | [opengraph.ing](https://opengraph.ing/) |
282
- | JSON-LD eligibility | [Rich Results Test](https://search.google.com/test/rich-results) |
283
- | JSON-LD syntax against schema.org | [Schema Markup Validator](https://validator.schema.org/) |
284
- | Sitemap XML | [XML Sitemaps validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html) |
285
-
286
- X (Twitter) has no working public card preview: confirm the `twitter:*` tags in the fetched HTML.
287
-
288
- ### A generated OG image on a self-hosted Next site
289
-
290
- When pages have no designed share image, render one per route instead of modelling an image
291
- field for it. The image reads the entry's own title, so it follows every content edit:
292
-
293
- ```tsx
294
- // app/og/[slug]/route.tsx
295
- import { ImageResponse } from "next/og";
296
- import { cms } from "@/lib/cms"; // createBetterCMS(...)
297
-
298
- export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
299
- const { slug } = await params;
300
- const entry = await cms.getEntry<{ title: string }>(slug);
301
- if (!entry) return new Response("Not found", { status: 404 });
302
- return new ImageResponse(
303
- <div style={{ display: "flex", width: "100%", height: "100%", padding: 64, fontSize: 64 }}>
304
- {entry.fields.title}
305
- </div>,
306
- { width: 1200, height: 630 },
307
- );
308
- }
309
- ```
310
-
311
- Point the route's OG image at it with `update_content_entry` `meta.og.image` (or `update_page`
312
- for a page), or from `generateMetadata`. Rules: 1200×630, an absolute URL, a 404 (never a blank image) for a
313
- missing slug, and strip stega from any preview string first ([bindings.md](bindings.md) §5).
314
- A hosted site gets its OG image from the native value; this route is for self-hosted Next only.
315
-
316
- ## SEO and AEO gate
317
-
318
- Run before publishing or reporting done, then run `list_seo_issues` and `get_next_steps` and fix
319
- what they list. The accessibility half of the gate is the check list in
320
- [accessibility.md](accessibility.md).
321
-
322
- **SEO**
323
-
324
- - [ ] No model, page or component carries a meta, OG, canonical, noindex, hreflang or schema field.
325
- - [ ] Every indexable page and entry has a `metaTitle` of at most 60 characters and a
326
- `metaDescription` (aim 120–160, never over 160). No two titles match.
327
- - [ ] Source canonicals that point elsewhere are kept; self canonicals are not stored.
328
- - [ ] Source noindex pages are `noindex`, and so are search, cart, checkout and thank-you pages.
329
- - [ ] Each language is its own row with `locale` and `translationOf`.
330
- - [ ] Every changed URL has a 301 to its final URL; the source's redirects are imported; no chain,
331
- no loop, no batch pointed at the home page.
332
- - [ ] `seoDefaults`, `siteSchema` and `collectionSchemaTypes` are set.
333
- - [ ] Sitemap and robots are `auto`, or `custom` carrying the source's rules.
334
- - [ ] Native values and the fetched HTML agree on representative static and dynamic routes.
335
-
336
- **Structured data**
337
-
338
- - [ ] Each page is on Automatic unless a type is missing; then `schemaType`; pasted `schema` last.
339
- - [ ] The emitted JSON-LD matches the source's for type and key values, and validates.
340
- - [ ] Every value is visible on the page.
341
- - [ ] One FAQPage per URL. No self-serving `AggregateRating`.
342
- - [ ] Pasted JSON-LD parses and has its type's required properties.
343
-
344
- **AEO**
345
-
346
- - [ ] Specs, prices, hours, menus and chart data are text in fields or tables.
347
- - [ ] Names are consistent and come from referenced entries.
348
- - [ ] `llms.txt` has a site summary and Markdown twins are on.
349
- - [ ] Videos and podcasts have a transcript, or the gap is reported.
350
- - [ ] The source's AI-crawler policy is carried over unchanged.
351
-
352
- **Report**
353
-
354
- - [ ] OG and Twitter previews use valid media and route-specific copy.
355
- - [ ] Internal links and redirects resolve; staging and private pages stay out of the index.
356
- - [ ] Fixed technical issues, content recommendations and unverified search outcomes are listed
357
- separately, with every unsupported directive, missing transcript and page that should be gone.
1
+ ---
2
+ title: "SEO and AEO"
3
+ description: "Native page and entry meta, carrying over a source site's meta, redirects, Markdown twins, route audit, structured data by content type, sitemap, robots and AI crawlers, AEO, validators, a generated OG image route and the SEO/AEO gate."
4
+ ---
5
+
6
+ # SEO and AEO
7
+
8
+ **Outcome.** Every publishable route sends correct technical signals and carries useful,
9
+ attributable content. Keep an implemented change separate from a search-engine outcome, which
10
+ no one can guarantee.
11
+
12
+ Doctrine: SEO is native and never modelled (§5b of `bettercms://playbook/schema`); flow:
13
+ `seo_optimize` prompt.
14
+
15
+ ## Contents
16
+
17
+ - 1. Where each value lives (carry over the source's meta, Markdown twins)
18
+ - 2. Redirects and URLs
19
+ - 3. Route-level audit
20
+ - 4. Structured data (content to schema.org type, rules)
21
+ - 5. Sitemap, robots, llms.txt (AI crawlers)
22
+ - 6. AEO and content quality
23
+ - 7. Publish and verify (validators, generated OG image)
24
+ - SEO and AEO gate
25
+
26
+ ## 1. Where each value lives
27
+
28
+ | Value | Writer | Notes |
29
+ |---|---|---|
30
+ | Page title, description, `schemaType`, `schema`, noindex, canonical, OG, Twitter | `update_page` | Merges into what is stored: an absent key keeps its value, `null` or `""` clears it |
31
+ | Entry meta (title, description, noindex, canonical, OG, Twitter, `schemaType`, `schema`) | `update_content_entry` with `meta` | Same merge rule |
32
+ | Site defaults (`seoDefaults`: title, description, OG image, Twitter handle, `og`, `twitter`, `collectionSchemaTypes`), site JSON-LD (`siteSchema`), `robotsConfig`, `sitemapConfig`, `rssConfig`, `indexnowConfig`, `searchConfig` | `update_seo`, read with `get_seo` | Each key REPLACES its whole object, so read and send it complete. `collectionSchemaTypes` maps a collection's model id to its entries' schema.org type; a key the plan lacks answers 403 `FEATURE_LOCKED` |
33
+ | Suggestions | `generate_seo_meta` | Returns a suggestion only; apply it with the writers above |
34
+ | Audit | `list_seo_issues` | Computed live from published pages; nothing is stored. Also reports accessibility issues as `a11y.` codes (image alt text, heading order, generic link text, video transcripts), each naming the page and its block ids |
35
+ | Redirects | `list_redirects`, `create_redirect`, `import_redirects`, `update_redirect`, `delete_redirect` | 301, 302, 307 or 308; 301 when omitted. 410 takes no destination: the path answers Gone and leaves the sitemap. For a list of rules use `import_redirects` (rows, CSV, a Netlify `_redirects` file or a vercel.json) with `dryRun: true` first; each row comes back created, existing or skipped with its reason |
36
+
37
+ Never create `seoTitle`-style fields on a model, page or component: the renderer, the sitemap,
38
+ `llms.txt` and `list_seo_issues` read the native values only. Self-hosted sites own their
39
+ `<head>`, sitemap and robots; the platform renderer emits them only for hosted sites, so confirm
40
+ the frontend emits the native values in its HTML ([next.md](next.md) covers `buildMetadata`).
41
+ There is no hreflang field: translations are native rows (`locale`, `translationOf`). The hosted
42
+ sitemap emits the hreflang alternates from them; a frontend emits the `<head>` links from each
43
+ entry's delivered `translations` ([localization.md](localization.md)).
44
+
45
+ ### Carry over the source's meta
46
+
47
+ On an import or conversion, read every source page's `<head>` before writing. Copy published
48
+ values as they are; rewriting copy is a separate, reported recommendation.
49
+
50
+ | Source | Native writer and key | Rule |
51
+ |---|---|---|
52
+ | `<title>` | `metaTitle` (`update_page`; `meta.metaTitle` on an entry) | Copy verbatim. Report one over 60 characters; `list_seo_issues` flags missing, over 60 and duplicates |
53
+ | `<meta name="description">` | `metaDescription` | Copy verbatim. Aim for 120–160 characters; the audit flags only a missing one and one over 160. When the source has none, take a `generate_seo_meta` suggestion and list it for review |
54
+ | `og:title`, `og:description`, `og:image`, `og:type` | `og.title`, `og.description`, `og.image`, `og.type` | Write a key only when it differs from the meta title, the description or the site default. Upload the image first and store its media URL |
55
+ | `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` | `twitter.card`, `twitter.title`, `twitter.description`, `twitter.image` | Same rule as OG |
56
+ | `twitter:site` | `update_seo` `seoDefaults.twitterHandle` | Once per site. A BetterCMS-built import fills it, the card and `og:type` from the home page's head where the project holds none |
57
+ | `keywords`, `author`, `theme-color`, a site verification tag | nothing | No native key stores them. The binding report's `elementGaps` lists each by name (`HEAD_META_UNMAPPED`); report them |
58
+ | A site-wide share image | `update_seo` `seoDefaults.ogImage` | Then pages inherit it and `og.image` stays empty |
59
+ | Canonical pointing at the page itself | nothing | A hosted page gets its own canonical |
60
+ | Canonical pointing at another URL | `canonical` | Keep it: someone consolidated those URLs on purpose |
61
+ | `<meta name="robots" content="noindex">` | `noindex: true` | Also drops the route from the sitemap, `llms.txt`, its Markdown twin and site search |
62
+ | `nofollow`, `noarchive`, `max-snippet` and other robots directives | nothing | No native key stores them. Report each page that used one |
63
+ | `<link rel="alternate" hreflang>` | nothing on the page; one row per language with `locale` and `translationOf` | Pair the languages by URL and structure, never by matching text ([localization.md](localization.md)) |
64
+ | JSON-LD on a page | nothing | Read it as evidence and compare it with what BetterCMS emits (§4) |
65
+ | Organization JSON-LD on the home page | `update_seo` `siteSchema` | Keep name, URL, logo and `sameAs`; drop any value the site does not show |
66
+ | robots.txt | `update_seo` `robotsConfig` | Stay on `auto` unless the source has rules worth keeping, then `custom` with that text (§5) |
67
+ | sitemap.xml | `update_seo` `sitemapConfig` | Stay on `auto`. URLs the CMS does not own (a docs path, a static app) go in `extraUrls` |
68
+ | RSS or Atom feed | `update_seo` `rssConfig` `{ enabled: true }` | Item links come from the collection's `urlPattern` |
69
+
70
+ ### Markdown twins
71
+
72
+ Every published page and entry is also served as Markdown for answer engines, at its own URL plus
73
+ `.md` (the home page at `/index.md`; an entry at its parent page's path, then `/<slug>.md`).
74
+
75
+ - The twin is generated from the content, per request. Nothing to model or keep in sync.
76
+ - A noindex page or entry has no twin, and is left out of `llms.txt`.
77
+ - To replace one twin, store a Markdown file at the same path in the dashboard (Pages →
78
+ Markdown). Only a published file serves; deleting it restores the generated twin. A second file
79
+ at the same path is refused (409, code markdown_path_taken).
80
+ - `llms.txt`, `llms-full.txt` and which pages serve a twin are switched there too. Those writes
81
+ need the SEO permission, and no MCP tool writes them.
82
+
83
+ ## 2. Redirects and URLs
84
+
85
+ Keep every URL that ranks or is linked to. Change a path only when BetterCMS cannot express it.
86
+
87
+ 1. Build a URL map first: each source URL (from the sitemap and a crawl) beside its new path.
88
+ 2. Every row whose path changes gets one `create_redirect` (301) aimed at the FINAL URL, never at
89
+ an intermediate one. Chains are collapsed for you; a loop answers 422 and a source path that
90
+ already has a rule answers 409.
91
+ 3. Import the source's own rules as well: a `_redirects` file, `vercel.json`, `netlify.toml`,
92
+ `.htaccess` or a redirect plugin's export.
93
+ 4. Never send a batch of removed pages to the home page. Redirect each to its closest equivalent,
94
+ or retire it with a 410.
95
+ 5. 302 or 307 is for a temporary move, 308 when the request method must survive.
96
+ 6. A page gone for good with no equivalent takes `redirectType: "410"` and no destination. List
97
+ those in the report.
98
+
99
+ | Case | Do |
100
+ |---|---|
101
+ | Slug shape | Lowercase letters, digits and hyphens. Strip `.html`, a trailing slash and capitals, each with a 301. Keep the source's words: a slug is not copy to improve |
102
+ | Collection URLs | Set `urlPattern` (`/blog/:slug`): a fixed prefix and `:slug` exactly once |
103
+ | Nested (`/:category/:slug`), dated (`/2024/05/post`) or query-string (`?p=123`) URLs | The pattern cannot say these. Map each to the flat pattern and add one 301 per old URL |
104
+ | Two sources want one slug | The one with more links or traffic keeps it. Give the other a suffix from its own words (`-guide`, the city), add its 301 and report it. A taken model slug answers 409 (code slug_taken) |
105
+ | A page or entry slug changed in BetterCMS | The 301 is written by the same save (an entry needs its collection's `urlPattern`). Do not add a second one |
106
+ | A URL folder renamed or moved | 301s are written for every page under it. Do not add them again |
107
+ | A closed job, a past event, a sold-out product | Keep the URL and mark the item closed in its content. Do not delete it |
108
+
109
+ ## 3. Route-level audit
110
+
111
+ Read the route inventory, page and entry records, site defaults, the rendered `<head>`, robots,
112
+ sitemap, structured data and redirects. For each indexable route check:
113
+
114
+ - HTTP status, canonical, title, description, the main heading, language, breadcrumbs,
115
+ internal links, image alt text and the social preview.
116
+ - Page type and structured-data eligibility; schema values must match visible content.
117
+ - Duplicate or near-duplicate routes, pagination, query parameters, redirects, trailing slash.
118
+ - Robots and noindex conflicts, staging or private pages that got indexed, sitemap inclusion.
119
+ - Client-rendered content: does the initial HTML carry the key content?
120
+
121
+ A CMS field holding the right title does not prove the public route emits it. Fetch the HTML.
122
+
123
+ ## 4. Structured data
124
+
125
+ `list_seo_issues` reports a pasted `schema` that lacks its type's required properties as
126
+ `schema.invalid`, naming the type and what is missing.
127
+
128
+ Choose in this order, and stop at the first that expresses the page:
129
+
130
+ 1. **Automatic.** With no `schemaType` and no `schema`, JSON-LD is built from the content at
131
+ render time and follows every edit: WebSite on the home page (plus Organization when the site
132
+ has a name and a logo, or `siteSchema` when one is stored), Blog on a list of posts,
133
+ CollectionPage on any other list, BlogPosting on a post entry, Product on an entry of a
134
+ collection with a price or SKU, WebPage everywhere else. FAQPage is never automatic. A hosted
135
+ page or entry also gets a BreadcrumbList beside its main type: Home, each ancestor path that
136
+ is itself a published page (a folder with no page is skipped), then the document.
137
+ 2. **`schemaType`.** One of nineteen: WebPage, AboutPage, ContactPage, CollectionPage, Blog,
138
+ BlogPosting, Article, Product, FAQPage, Event, Organization, LocalBusiness, JobPosting, Course,
139
+ Recipe, HowTo, Service, Person, VideoObject. Its properties are filled from the document's own
140
+ fields by key (a job's `company`, a recipe's `ingredients`), and a property the content lacks
141
+ is left out. A type missing what it cannot do without (a JobPosting with no company or posting
142
+ date, a Recipe with no image, a HowTo with no steps) is not emitted: the document keeps its
143
+ automatic type until the field is filled. Clear it with `"auto"`
144
+ on `update_page` or `null` in an entry's `meta`. For a whole collection, set
145
+ `seoDefaults.collectionSchemaTypes` once instead of typing every entry.
146
+ 3. **Pasted `schema`.** Only when neither of the above can express the type AND every value in it
147
+ is visible on the page. It wins over both and does not follow content edits, so it goes stale.
148
+
149
+ JSON-LD found on the source site is evidence, not content. Use it to fill fields (event dates,
150
+ opening hours, prices) and to check that the emitted type and key values match. Do not paste it
151
+ back. Never add a second copy through custom head code.
152
+
153
+ ### Content to schema.org type
154
+
155
+ | Source content | Type | How in BetterCMS |
156
+ |---|---|---|
157
+ | The whole site | Organization, WebSite | Automatic on the home page; `siteSchema` to add `sameAs` or replace it |
158
+ | Blog or journal post | BlogPosting, or Article | Automatic for a collection with a body that is named for posts or has a date and an author; else `collectionSchemaTypes` |
159
+ | List of posts, any other list | Blog, CollectionPage | Automatic |
160
+ | Product | Product with an Offer | Automatic when the model has a price or SKU; else `schemaType` |
161
+ | FAQ: visible questions with answers | FAQPage | `schemaType: "FAQPage"`. It reads the first repeater whose rows hold a question and an answer |
162
+ | Event | Event | `schemaType` (name, start, end, location read from the fields) |
163
+ | Company, store, restaurant, office | Organization, LocalBusiness | `schemaType`; a subtype such as Restaurant, a `PostalAddress`, geo or opening hours need pasted `schema` |
164
+ | About or contact page | AboutPage, ContactPage | `schemaType` |
165
+ | Breadcrumb trail | BreadcrumbList | Automatic on hosted pages (ancestors that have a page). Paste `schema` only to match a trail the page shows differently |
166
+ | News, recipe, job, video, course, podcast episode, how-to, author profile | NewsArticle, Recipe, JobPosting, VideoObject, Course, PodcastEpisode, HowTo, ProfilePage | Pasted `schema`, visible values only |
167
+ | Product with on-page reviews | AggregateRating, Review | Pasted `schema`, with the average and count the page displays |
168
+
169
+ ### Rules
170
+
171
+ - Every value must be on the page. No invented rating, review count, price, availability, salary,
172
+ author or date. What the page does not show is left out.
173
+ - One FAQPage per URL and one main entity per page. Two FAQ sections on one page still make one
174
+ FAQPage. An accordion of features is not a FAQ.
175
+ - `AggregateRating` needs a product or another reviewable subject. Never on the home page about
176
+ the business itself, and never typed by hand.
177
+ - Awards go in `award`; they are not certifications.
178
+ - Dates are ISO 8601 with a time zone offset.
179
+ - Cart, checkout, 404 and legal pages stay on Automatic (plain WebPage). There is no off switch;
180
+ never give them a richer type.
181
+ - Pasted JSON-LD is stored as given and is not checked for required properties. Check them
182
+ yourself before saving:
183
+
184
+ | Type | Required |
185
+ |---|---|
186
+ | Article, BlogPosting | `headline`, `image` |
187
+ | Product | `name` plus one of `offers`, `review`, `aggregateRating` |
188
+ | Recipe | `name`, `image` |
189
+ | FAQPage | `mainEntity`, each with `name` and `acceptedAnswer.text` |
190
+ | BreadcrumbList | `itemListElement`, each with `position` and `name` |
191
+ | Organization | `name`, `url` |
192
+ | LocalBusiness | `name`, `address` |
193
+ | Event | `name`, `startDate`, `location` |
194
+
195
+ Valid markup does not guarantee a rich result: [Google structured-data guidelines](https://developers.google.com/search/docs/appearance/structured-data/sd-policies).
196
+
197
+ ## 5. Sitemap, robots, llms.txt
198
+
199
+ - The hosted sitemap leaves out noindex pages, and the generated robots.txt points at the
200
+ sitemap. Confirm drafts, private pages and noindex pages are absent from the live file.
201
+ - A translated page or entry carries `<xhtml:link rel="alternate" hreflang>` for each listed
202
+ sibling (its `locale`, else the project's `localeConfig.defaultLanguage`) plus `x-default` for
203
+ the source row. A noindex or unpublished sibling is left out of the links as well.
204
+ - `sitemapConfig` sets it `auto` or `custom`, the default changefreq and priority and `extraUrls`;
205
+ `robotsConfig` is `auto` (with `disallow`: AI crawler names such as `GPTBot` or `ClaudeBot` to block) or `custom` text, which wins when set; `indexnowConfig` { enabled, excludePatterns } pings
206
+ IndexNow on publish; `searchConfig` { enabled, placeholder, aiEnabled } is site search, which
207
+ `search_content` queries (published content only).
208
+ - `refresh_search_index` costs nothing: `action: 'indexnow'` submits `urls` (this site's own, up
209
+ to 1000) or, without them, every change since the last submission; `action: 'reindex'` rebuilds
210
+ site search after bulk changes. A 503 means the index is unreachable: retry later.
211
+ - `llms.txt` lists one link per published item. Maintain it for its intended readers, but do not
212
+ sell it as a ranking requirement: Google's [guidance for AI features](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)
213
+ says ordinary SEO fundamentals are the base.
214
+ - `set_site_file` stores a hand-written `llms.txt` or `llms-full.txt` (plain text, up to 5 MB).
215
+ Give `llms.txt` a one-line site summary taken from the home page's description or About copy.
216
+ - Check each redirect's real status and target, and detect loops.
217
+
218
+ ### AI crawlers
219
+
220
+ `robotsConfig` is `{ mode: "auto" }` or `{ mode: "custom", custom: "<robots.txt text>" }` (up to
221
+ 10,000 characters). `robotsConfig.disallow` is the per-bot switch: a list of names from GPTBot,
222
+ OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-Web, anthropic-ai, PerplexityBot, Google-Extended,
223
+ CCBot, Bytespider, Applebot-Extended, Amazonbot and Meta-ExternalAgent. Each named bot is
224
+ disallowed from the whole site in auto mode. In custom mode the custom text is served as written
225
+ and `disallow` is not applied; any other bot or a path rule is a line of custom text.
226
+
227
+ - Carry the source's AI-bot rules over exactly. Changing the owner's crawler policy is not part of
228
+ a conversion. No rules in the source: stay on `auto` and say so in the report.
229
+ - Custom text replaces the generated file, so include the `Sitemap:` line yourself.
230
+ - Blocking `Google-Extended` or `Applebot-Extended` leaves Google Search and Siri untouched.
231
+ Blocking an answer or search bot (`OAI-SearchBot`, `ChatGPT-User`, `PerplexityBot`,
232
+ `DuckAssistBot`) removes the site from that product's answers. Say so before the owner asks
233
+ for it.
234
+ - Other agents to look for in a source file: `GPTBot`, `ClaudeBot`, `Claude-Web`, `anthropic-ai`,
235
+ `Meta-ExternalAgent`, `FacebookBot`, `Amazonbot`, `cohere-ai`, `CCBot`, `Bytespider`.
236
+
237
+ ```text
238
+ User-agent: *
239
+ Allow: /
240
+
241
+ User-agent: CCBot
242
+ Disallow: /
243
+
244
+ Sitemap: https://caldera.com/sitemap.xml
245
+ ```
246
+
247
+ ## 6. AEO and content quality
248
+
249
+ Set these up while converting, without asking:
250
+
251
+ | Do | How |
252
+ |---|---|
253
+ | Facts as text | Specs, prices, menus, opening hours and chart data become fields or a real table, never an image of them |
254
+ | Answer first | A real question on the page becomes a FAQ row: the question as asked, the answer in the first one or two sentences, detail after |
255
+ | One name per thing | A product, plan, person or brand is one entry that other content references, so a rename reaches every page and feed |
256
+ | Dated facts | A statistic or comparison keeps its date and source in fields beside the number |
257
+ | Authors as entries | An author collection with name, bio and photo, referenced from each post |
258
+ | Transcripts | Audio and video carry one, because crawlers do not watch video. It lives on the asset (`update_media` `transcript`); never invent one ([accessibility.md](accessibility.md)) |
259
+ | Twins and `llms.txt` | Leave them on unless the owner turned them off |
260
+
261
+ Improve the page for people first: clear questions and answers where the topic warrants them,
262
+ descriptive headings, first-hand evidence, dates and named authors where real, text alongside
263
+ media, consistent internal links and accurate structured data. No "AI-only" pages, artificial
264
+ snippets or unverifiable claims.
265
+
266
+ Report concrete findings ("canonical points to a 404", "OG image missing", "FAQ schema does not
267
+ match the visible answers"), never an untraceable score.
268
+
269
+ ## 7. Publish and verify
270
+
271
+ After an authorized change ([publishing.md](publishing.md)): inspect the rendered `<head>` and JSON-LD,
272
+ route status, sitemap and robots output, and redirects. Use Search Console or similar only to
273
+ report observed behaviour after launch, never to predict indexing.
274
+
275
+ Validators, run against the live URL (they read the public HTML, never the CMS):
276
+
277
+ | Checks | Tool |
278
+ |---|---|
279
+ | Open Graph tags as Facebook reads them; refreshes its cache | [Sharing Debugger](https://developers.facebook.com/tools/debug/) |
280
+ | LinkedIn card | [Post Inspector](https://www.linkedin.com/post-inspector/) |
281
+ | Any OG card | [opengraph.ing](https://opengraph.ing/) |
282
+ | JSON-LD eligibility | [Rich Results Test](https://search.google.com/test/rich-results) |
283
+ | JSON-LD syntax against schema.org | [Schema Markup Validator](https://validator.schema.org/) |
284
+ | Sitemap XML | [XML Sitemaps validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html) |
285
+
286
+ X (Twitter) has no working public card preview: confirm the `twitter:*` tags in the fetched HTML.
287
+
288
+ ### A generated OG image on a self-hosted Next site
289
+
290
+ When pages have no designed share image, render one per route instead of modelling an image
291
+ field for it. The image reads the entry's own title, so it follows every content edit:
292
+
293
+ ```tsx
294
+ // app/og/[slug]/route.tsx
295
+ import { ImageResponse } from "next/og";
296
+ import { cms } from "@/lib/cms"; // createBetterCMS(...)
297
+
298
+ export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
299
+ const { slug } = await params;
300
+ const entry = await cms.getEntry<{ title: string }>(slug);
301
+ if (!entry) return new Response("Not found", { status: 404 });
302
+ return new ImageResponse(
303
+ <div style={{ display: "flex", width: "100%", height: "100%", padding: 64, fontSize: 64 }}>
304
+ {entry.fields.title}
305
+ </div>,
306
+ { width: 1200, height: 630 },
307
+ );
308
+ }
309
+ ```
310
+
311
+ Point the route's OG image at it with `update_content_entry` `meta.og.image` (or `update_page`
312
+ for a page), or from `generateMetadata`. Rules: 1200×630, an absolute URL, a 404 (never a blank image) for a
313
+ missing slug, and strip stega from any preview string first ([bindings.md](bindings.md) §5).
314
+ A hosted site gets its OG image from the native value; this route is for self-hosted Next only.
315
+
316
+ ## SEO and AEO gate
317
+
318
+ Run before publishing or reporting done, then run `list_seo_issues` and `get_next_steps` and fix
319
+ what they list. The accessibility half of the gate is the check list in
320
+ [accessibility.md](accessibility.md).
321
+
322
+ **SEO**
323
+
324
+ - [ ] No model, page or component carries a meta, OG, canonical, noindex, hreflang or schema field.
325
+ - [ ] Every indexable page and entry has a `metaTitle` of at most 60 characters and a
326
+ `metaDescription` (aim 120–160, never over 160). No two titles match.
327
+ - [ ] Source canonicals that point elsewhere are kept; self canonicals are not stored.
328
+ - [ ] Source noindex pages are `noindex`, and so are search, cart, checkout and thank-you pages.
329
+ - [ ] Each language is its own row with `locale` and `translationOf`.
330
+ - [ ] Every changed URL has a 301 to its final URL; the source's redirects are imported; no chain,
331
+ no loop, no batch pointed at the home page.
332
+ - [ ] `seoDefaults`, `siteSchema` and `collectionSchemaTypes` are set.
333
+ - [ ] Sitemap and robots are `auto`, or `custom` carrying the source's rules.
334
+ - [ ] Native values and the fetched HTML agree on representative static and dynamic routes.
335
+
336
+ **Structured data**
337
+
338
+ - [ ] Each page is on Automatic unless a type is missing; then `schemaType`; pasted `schema` last.
339
+ - [ ] The emitted JSON-LD matches the source's for type and key values, and validates.
340
+ - [ ] Every value is visible on the page.
341
+ - [ ] One FAQPage per URL. No self-serving `AggregateRating`.
342
+ - [ ] Pasted JSON-LD parses and has its type's required properties.
343
+
344
+ **AEO**
345
+
346
+ - [ ] Specs, prices, hours, menus and chart data are text in fields or tables.
347
+ - [ ] Names are consistent and come from referenced entries.
348
+ - [ ] `llms.txt` has a site summary and Markdown twins are on.
349
+ - [ ] Videos and podcasts have a transcript, or the gap is reported.
350
+ - [ ] The source's AI-crawler policy is carried over unchanged.
351
+
352
+ **Report**
353
+
354
+ - [ ] OG and Twitter previews use valid media and route-specific copy.
355
+ - [ ] Internal links and redirects resolve; staging and private pages stay out of the index.
356
+ - [ ] Fixed technical issues, content recommendations and unverified search outcomes are listed
357
+ separately, with every unsupported directive, missing transcript and page that should be gone.