> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hiveku.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pre-Publish SEO Check

> The on-page check that runs before a content item reaches a site: what it checks, how it scores, where it runs (the editor, the dashboard and Olympus APIs, the MCP tool, the publish route), and why it warns but never blocks

Nothing between the writer and the live page used to look at the copy. Publishing validated the collection schema (required fields, slug shape, field types) and the editor showed 60/160 character counters, so a post with no meta description, two H1s, a keyword-less slug and an alt-less hero image published with a 200, and the customer learned about it from a later SEO audit, if ever.

The pre-publish check closes that gap with one deterministic function that every surface runs: the same inputs always give the same findings, so the editor can re-run it on every keystroke, the API can run it on the stored row, and the Content department can quote it verbatim. It has no network and no clock. It never blocks a publish: errors ask for a confirmation in the editor and come back as warnings on the API, and the decision stays with the person publishing.

## Where it runs

<CardGroup cols={2}>
  <Card title="The marketing editor" icon="pen-to-square">
    A **Pre-publish check** section sits above **Status** in the item's sidebar, so its findings are above every publish control: a score chip out of 100, errors under **Fix before publishing**, warnings under **Worth a look**. Each row names the field and clicking it jumps there (title, slug, meta title, meta description, target keyword, alt text, body). The report is fetched when the item opens and re-run locally on every edit, so it tracks what is being typed.
  </Card>

  <Card title="The publish gate" icon="rocket">
    Every publish entry point in the editor (the header action, **Publish changes** in the sidebar, the **Publish to** dialog, the status flip on an unlinked item) goes through one gate: unsaved edits are saved, the server check runs again, and errors open **Publish anyway?** listing them, with **Go back** and **Publish anyway**. Warnings never interrupt, and a check that cannot run never blocks.
  </Card>

  <Card title="The APIs" icon="code">
    `GET /api/olympus/marketing/content/:contentId/seo-check` under an Olympus API key, and its dashboard twin `GET /api/marketing/content/{id}/seo-check` for a signed-in user with `marketing.content` read. Same loader, same response; a row outside the account is a 404. From an MCP client the `content_seo_check` tool calls the Olympus route.
  </Card>

  <Card title="The publish route" icon="upload">
    `POST /api/olympus/marketing/content/:contentId/publish-to-site` (`content_publish_to_site`) runs the check on the row as it is about to go out and returns `warnings: string[]` (one line each, `Error (field): ...` or `Warning (field): ...`) plus the structured `seo_check`. The note tells the department to state each one before treating the publish as done. Unpublish is not checked.
  </Card>
</CardGroup>

The [Content department](/marketing/content-department#the-pre-publish-check) also states the list with a pass or fail per item in its reply before it calls the publish tool, so a reviewer sees the check twice: once as the department's own statement, once as the route's verdict.

## What it checks

Two levels. An **error** is something the site will visibly get wrong: a missing meta description, a body with two H1s, an image with no alt text, a banned phrase, an empty body. A **warning** is a ranking or editorial miss the writer should look at: keyword placement, heading order, few internal links, a figure with no source.

| Check                       | Level | Field                         | Rule                                                                                                                                                                                      |
| --------------------------- | ----- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title_missing`             | error | `title`                       | The title is empty                                                                                                                                                                        |
| `title_exclamation`         | error | `title` or `meta_title`       | The title or the meta title contains an exclamation mark                                                                                                                                  |
| `meta_title_missing`        | error | `meta_title`                  | No meta title; search results fall back to the page title                                                                                                                                 |
| `meta_title_too_long`       | warn  | `meta_title`                  | Longer than 60 characters; search results cut it there                                                                                                                                    |
| `meta_description_missing`  | error | `meta_description`            | No meta description; search engines pick a snippet themselves                                                                                                                             |
| `meta_description_too_long` | warn  | `meta_description`            | Longer than 160 characters                                                                                                                                                                |
| `slug_missing`              | warn  | `slug`                        | No slug yet; it is derived from the title on save                                                                                                                                         |
| `slug_placeholder`          | error | `slug`                        | The slug is still `untitled-content` or `untitled-content-N`                                                                                                                              |
| `content_empty`             | error | `content`                     | The body has no words                                                                                                                                                                     |
| `thin_content`              | warn  | `content`                     | Fewer than 300 words                                                                                                                                                                      |
| `h1_multiple`               | error | `content`                     | Two or more H1 headings in the body                                                                                                                                                       |
| `h1_keyword_missing`        | warn  | `content`                     | The body has an H1 and neither it nor the title carries the target keyword                                                                                                                |
| `target_keyword_missing`    | warn  | `target_keyword`              | No target keyword is set, so keyword placement was not checked                                                                                                                            |
| `keyword_in_title`          | warn  | `title`                       | The title does not contain the target keyword                                                                                                                                             |
| `keyword_in_slug`           | warn  | `slug`                        | The slug does not contain the target keyword                                                                                                                                              |
| `keyword_in_intro`          | warn  | `content`                     | The keyword does not appear in the first 100 words                                                                                                                                        |
| `heading_hierarchy`         | warn  | `content`                     | A heading skips a level (an H4 with no H3 before it); the title is level 1, so a body may start at H2                                                                                     |
| `hero_alt_missing`          | error | `featured_image_alt`          | The featured image is set and has no alt text (a warning when the caller does not say whether there is a featured image; not checked when there is none)                                  |
| `inline_image_alt_missing`  | error | `content`                     | One or more inline images have no alt text; the first offending source is named                                                                                                           |
| `internal_links_few`        | warn  | `content`                     | Fewer than two internal links: site-relative paths, or absolute links on the site's own host                                                                                              |
| `claims_without_source`     | warn  | `content`                     | A sentence states a figure (a percentage, a currency amount, a multiplier, a million/billion figure or a comma-grouped number) in a paragraph with no link; up to two examples are quoted |
| `banned_phrase`             | error | the first field it appears in | A brand guide forbidden phrase appears in the title, meta title, meta description or body, matched case-insensitively as a whole phrase                                                   |

Three rules behind the table are worth knowing:

* **One H1 is the page title.** Site templates render the item's title as the page H1, so a body with no H1 is the normal, correct shape. A body with exactly one H1 is accepted (some imports carry the title inside the body); two or more is an error.
* **Markdown and HTML are both read.** Rows hold either, and the stored format hint is not reliable (the editor converts markdown to HTML on load and saves the HTML back), so the format is detected from the content and the hint only breaks ties. Code blocks are ignored; image syntax and links are reduced to prose before words are counted.
* **Internal is relative to the site.** A site-relative path counts as internal, and so does an absolute URL on the linked project's production host, so a link back to `https://www.example.com/blog/...` counts on `www.example.com`. Anchors, `mailto:` and `tel:` links count as neither.

## The score and the response

`ok` is true when there are no errors; warnings never flip it. `score` is 100 minus 15 per error and 5 per warning, floored at 0. `checks` lists errors first, then warnings, each in the order above.

```json theme={null}
{
  "data": {
    "content_id": "a1b2c3d4-...",
    "checked_at": "2026-09-11T16:20:03.000Z",
    "result": {
      "ok": false,
      "score": 70,
      "checks": [
        { "id": "meta_description_missing", "level": "error", "field": "meta_description", "message": "Meta description is missing; search engines will pick a snippet themselves." },
        { "id": "keyword_in_slug", "level": "warn", "field": "slug", "message": "The slug does not contain the target keyword \"cnc uptime\"." },
        { "id": "internal_links_few", "level": "warn", "field": "content", "message": "Only 1 internal link; link at least 2 existing pages on the site." },
        { "id": "claims_without_source", "level": "warn", "field": "content", "message": "1 sentence states a figure with no source link in the same paragraph: \"Unplanned stops cost 23% of output in year three.\"." }
      ],
      "stats": {
        "word_count": 812,
        "h1_count": 0,
        "heading_count": 6,
        "image_count": 2,
        "images_missing_alt": 0,
        "internal_link_count": 1,
        "external_link_count": 3,
        "format": "markdown"
      }
    },
    "context": {
      "target_keyword": "cnc uptime",
      "banned_phrases": ["game-changer", "unlock"],
      "site_host": "www.example.com",
      "content_format": "markdown",
      "linked": true
    }
  }
}
```

`context` is what the check was given, so a client can re-run it locally on edits without another request: the target keyword from `settings.target_keyword`, the forbidden phrases from the account's default active brand guide (empty when there is none), the linked project's production host (its active primary production domain, else its custom domain, else its hiveku.com host), the stored format hint, and `linked`, which is true when the item is bound to a website project and a CMS collection. The slug checked is the site entry's slug once the entry exists, otherwise the library slug.

## What it does not do

* **It never blocks.** The editor asks **Publish anyway?** on errors; the publish route publishes and reports. A check that fails to run yields an empty list, not a failed publish.
* **It checks the item, not the collection.** A `meta_title` that has no home in the collection (no `seoTitle` or `metaTitle` field) is dropped at publish time and named in the publish response's `unmapped` list. The check cannot see that from the row, so read `unmapped` as well.
* **It does not read the live page.** Word counts, links and images are those of the stored body. The rendered template, the blog index and the deploy are outside its view.
* **Unpublish is not checked.** Taking a page down never waits on a meta description.

## Related pages

* [The Content Department](/marketing/content-department) — the writer that states this list before it publishes, and links to real URLs so the internal-link check passes on merit
* [Scheduling and the Content Calendar](/marketing/content-calendar) — a scheduled item is checked when it is published, and shows on the calendar
* [Use the Marketing Content Review Workflow](/how-tos/content-review-workflow) — the editor the check panel lives in
* [SEO Delivery Tools](/marketing/seo-delivery-tools) — the page audit and rank tracker that measure the published result
