# SEO and AI exports

Source: https://capacms.com/docs/guides/seo

Get schema.org JSON-LD, a Markdown copy of an entry, and a plain-text bundle of your content for search and retrieval.

Capa can turn your entries into three formats that search engines and AI tools read well:

| Export    | Route                                                     | Use it for                                                                    |
| --------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- |
| JSON-LD   | `/v2/seo/{model}/{id}/json-ld`, `/v2/seo/{model}/json-ld` | Structured data in a page's `<head>`.                                         |
| Markdown  | `/v2/seo/{model}/{id}/markdown`                           | A Markdown copy of a page, such as `/blog/my-post.md` or an `llms.txt` entry. |
| AI bundle | `/v2/seo/{model}/ai-bundle`, `/v2/seo/ai-bundle`          | Plain text and metadata, ready for search indexing or embeddings.             |

These routes are part of the legacy `/v2` surface. They take the legacy key your site already uses (`pk_`, `sk_`, or an older key with no prefix). A `cap_` key is refused with `401`.

None of them call an AI model. They reshape your content and nothing else.

## Drafts

The key decides what you get, as on every read:

* A production key (`pk_`) gets published content only. No parameter changes that.
* A development key (`sk_`) gets drafts by default. Add `?published=true` to see exactly what is live.

## JSON-LD for one entry

```bash
curl -G https://cdn.capacms.com/v2/seo/article/<entryId>/json-ld \
  -H "x-api-key: $CAPA_KEY" \
  --data-urlencode 'baseUrl=https://northpeak.example'
```

```json
{
  "@context": "https://schema.org",
  "@type": "Article",
  "@id": "https://northpeak.example/api/article/<entryId>",
  "identifier": "winter-field-guide-2026",
  "headline": "Winter Field Guide 2026: Layering Above Treeline",
  "description": "How to stay warm and dry above treeline this winter.",
  "articleBody": "…",
  "dateCreated": "2026-09-30T14:02:11.000Z",
  "dateModified": "2026-10-01T09:15:40.000Z",
  "datePublished": "2026-10-01T09:15:40.000Z",
  "name": "Winter Field Guide 2026: Layering Above Treeline",
  "url": "https://northpeak.example/article/winter-field-guide-2026"
}
```

The response is `application/ld+json`. Put it in your page inside `<script type="application/ld+json">`.

`identifier` is the entry's id, or its `slug` field when the model has one. `name` falls back to the entry's title.

### How the type is chosen

The schema.org `@type` comes from the model's namespace:

| Namespace                                                       | `@type`                                                       |
| --------------------------------------------------------------- | ------------------------------------------------------------- |
| `article`, `blog`, `blog_post`                                  | `Article`                                                     |
| `news`                                                          | `NewsArticle`                                                 |
| `post`                                                          | `BlogPosting`                                                 |
| `product`, `products`, `item`                                   | `Product`                                                     |
| `event`, `events`, `workshop`, `webinar`, `conference`          | `Event`                                                       |
| `person`, `people`, `author`, `team`, `team_member`, `employee` | `Person`                                                      |
| `organization`, `company`                                       | `Organization`                                                |
| `location`, `place`, `venue`                                    | `Place`                                                       |
| `store`                                                         | `LocalBusiness`                                               |
| `restaurant`                                                    | `Restaurant`                                                  |
| `recipe`                                                        | `Recipe`                                                      |
| `faq`                                                           | `FAQPage`                                                     |
| `review`, `testimonial`                                         | `Review`                                                      |
| `job`, `job_posting`                                            | `JobPosting`                                                  |
| `service`                                                       | `Service`                                                     |
| `video`, `image`, `audio`, `podcast`                            | `VideoObject`, `ImageObject`, `AudioObject`, `PodcastEpisode` |
| `question`, `book`, `movie`, `course`                           | `Question`, `Book`, `Movie`, `Course`                         |
| anything else                                                   | `Thing`                                                       |

The match is exact, after lowercasing and turning `-` into `_`. An `articles` model is `Thing`, not `Article`.

### How fields are mapped

Fields map to schema.org properties by their namespace. Some names work on every type:

| Field                                            | Property      |
| ------------------------------------------------ | ------------- |
| `title`, `name`                                  | `name`        |
| `description`                                    | `description` |
| `content`, `body`                                | `articleBody` |
| `image`, `images`, `thumbnail`, `featured_image` | `image`       |
| `author`                                         | `author`      |
| `slug`                                           | `identifier`  |
| `url`                                            | `url`         |

<Callout type="info">
  Images and video on 

  `cdn.capacms.com`

   can appear in search, so they work in rich results. Other files there, such as PDFs, are sent with 

  `X-Robots-Tag: noindex`

   and stay out of search results.
</Callout>

Some types add their own:

* **Article:** `title` becomes `headline`, and `summary` or `excerpt` becomes `description`.
* **Product:** `price` and `currency` become an `Offer`, plus `sku`, `brand`, `category`, `availability`, `rating` and `review_count`.
* **Event:** `start_date`, `end_date`, `location`, `venue`, `organizer`, `ticket_url` and `ticket_price`.
* **Person:** `first_name`, `last_name`, `email`, `phone`, `job_title`, `company`, `bio` and `avatar`.
* **Recipe:** `ingredients`, `instructions`, `prep_time`, `cook_time`, `total_time`, `servings`, `cuisine` and `calories`.

A field with no mapping is left out. Image fields become an `ImageObject`, and a relation becomes a reference by `@id`.

### `baseUrl`

Pass your site's origin as `baseUrl`. Capa uses it for `@id`, and, when the model has a `slug` field, for `url` as `<baseUrl>/<namespace>/<slug>`. Without `baseUrl` there is no `url`.

If your URLs are shaped differently, overwrite `url` in your own code before you print it.

## JSON-LD for a list

```bash
curl -G https://cdn.capacms.com/v2/seo/article/json-ld \
  -H "x-api-key: $CAPA_KEY" \
  --data-urlencode 'baseUrl=https://northpeak.example' \
  --data-urlencode 'limit=20'
```

This returns an `ItemList` with one `ListItem` per entry. `limit` is 1 to 500, and the default is 100.

## Markdown for one entry

```bash
curl https://cdn.capacms.com/v2/seo/article/<entryId>/markdown \
  -H "x-api-key: $CAPA_KEY"
```

```markdown
---
id: "<entryId>"
model: "article"
title: "Winter Field Guide 2026: Layering Above Treeline"
slug: "winter-field-guide-2026"
status: "published"
created_at: "2026-09-30T14:02:11.000Z"
updated_at: "2026-10-01T09:15:40.000Z"
published_at: "2026-10-01T09:15:40.000Z"
tags:
  - "winter"
---

# Winter Field Guide 2026: Layering Above Treeline

> How to stay warm and dry above treeline this winter.

The body of the entry, with HTML converted to Markdown.
```

The response is `text/markdown`, sent as a download named `<model>-<slug>.md`.

What goes where:

* **Front matter:** the entry's id, model, title, slug, status, dates and tags, plus any `author`, `category`, `featured` or `priority` field that holds plain text, a number or true/false.
* **Heading:** the first of `title`, `name` or `headline`.
* **Quote:** the first of `excerpt` or `summary`, cut to 160 characters.
* **Body:** the first of `content`, `body`, `text` or `description`.
* **Sections:** other long Markdown or HTML fields, and non-empty lists, each under its field's name.

| Parameter     | Default | What it does                                               |
| ------------- | ------- | ---------------------------------------------------------- |
| `frontmatter` | `true`  | `false` leaves the front matter out.                       |
| `metadata`    | `true`  | `false` leaves the dates and tags out of the front matter. |

## The AI bundle

One model:

```bash
curl -G https://cdn.capacms.com/v2/seo/article/ai-bundle \
  -H "x-api-key: $CAPA_KEY" \
  --data-urlencode 'limit=200'
```

Every model in the project:

```bash
curl https://cdn.capacms.com/v2/seo/ai-bundle -H "x-api-key: $CAPA_KEY"
```

```json
{
  "schema": { "namespace": "article", "modelName": "Article",
              "fields": [ { "namespace": "title", "name": "Title", "type": "string", "required": true } ] },
  "documents": [
    { "id": "<entryId>",
      "modelNamespace": "article",
      "text": "Winter Field Guide 2026: Layering Above Treeline\n\nHow to stay warm and dry above treeline this winter.\n\n…",
      "metadata": { "status": "published", "tags": ["winter"], "title": "Winter Field Guide 2026: Layering Above Treeline",
                    "slug": "winter-field-guide-2026", "createdAt": "…", "updatedAt": "…", "publishedAt": "…" },
      "embedding_text": "[Article] Winter Field Guide 2026: Layering Above Treeline | How to stay warm and dry … | Tags: winter" } ],
  "stats": { "documentCount": 1, "totalTextLength": 1840, "avgTextLength": 1840 }
}
```

The project-wide bundle returns `schemas` (one per model) instead of `schema`, and `stats` with `modelCount`, `documentCount` and `totalTextLength`.

* `text` joins the entry's title and its text fields (`content`, `body`, `text`, `description`, `excerpt`, `summary`), with HTML removed.
* `metadata` adds every short field: text, numbers, true/false, dates, options and colours.
* `embedding_text` is a shorter line built for embedding models.

| Parameter       | One model                           | Every model                              |
| --------------- | ----------------------------------- | ---------------------------------------- |
| `limit`         | entries, 1 to 1,000, default 500    | entries per model, 1 to 200, default 100 |
| `maxTextLength` | characters of `text`, default 8,000 | default 4,000                            |

## Caching and errors

With a production key, these routes are cached at the edge for 60 seconds and purged when you publish. Responses to a development key, and errors, are never cached.

| Status | Body                             | When                                              |
| ------ | -------------------------------- | ------------------------------------------------- |
| `401`  | `{"error":"API key required"}`   | No `x-api-key` header.                            |
| `401`  | `{"error":"Invalid API key"}`    | Unknown, deactivated or expired, or a `cap_` key. |
| `402`  | `{"success":false,"error":"…"}`  | The project has no active subscription.           |
| `404`  | `{"error":"Model not found"}`    | No model with that namespace.                     |
| `404`  | `{"error":"Instance not found"}` | No such entry, or one this key cannot see.        |

## Related

* [Legacy API](https://capacms.com/docs/legacy) for the rest of the `/v2` surface.
* [Keys](https://capacms.com/docs/concepts/keys) for the two key families.
