---
title: "Modelling content for answer engines in a headless CMS!"
subtitle: "How to turn every AEO signal into a constrained CMS field, and generate JSON-LD, Markdown and llms.txt from those fields, with Storyblok as the worked example."
canonical: https://citable.wiki/guides/modeling-content-for-answer-engines-in-a-headless-cms
category: technical
author: "Endrit Krasniqi"
date_published: 2026-05-25
date_modified: 2026-05-25
license: CC BY 4.0
---

# Modelling content for answer engines in a headless CMS!

*How to turn every AEO signal into a constrained CMS field, and generate JSON-LD, Markdown and llms.txt from those fields, with Storyblok as the worked example.*

## Short answer

A headless CMS content model for answer engines makes every Answer Engine Optimization signal a constrained field, not an editorial habit: a required short answer with a maximum length, key takeaways, FAQ items, sources with URL validation, the author as a relation, and published and updated dates. JSON-LD, Markdown and llms.txt are then generated from those fields, so no representation can disagree with the page.

## Key takeaways

- Every AEO signal should be a constrained CMS field, not an editorial habit, so it is present on every page by construction.
- Model the short answer, takeaways, FAQ and sources as required fields and nestable bloks with length and URL constraints.
- Author and related terms belong in relation fields, which give JSON-LD stable Person and DefinedTerm identifiers.
- Generate JSON-LD, Markdown and llms.txt from the same fields so no representation can disagree with the visible page.
- In Storyblok, seed the schema with the Management API, preview with Draft Mode and revalidate from publish webhooks.

## Why the content model is the AEO strategy

Most Answer Engine Optimization advice is a list of habits: open with a direct answer, add an FAQ, cite sources, show the author and the date, mark it up. Habits decay. The third writer forgets the short answer, the FAQ becomes body prose, and the JSON-LD template emits a `dateModified` nobody updates.

A headless CMS lets you move each habit into the **content model**. A required field with a maximum length is present on every page by construction, and a front end that derives its structured data, Markdown and `llms.txt` from those fields can never publish a representation that disagrees with the visible text.

The method in one sentence: **one set of fields, many generated representations**. What follows is the field list, the constraints, and the Storyblok plumbing Citable uses.

> **Note: Templates cannot enforce anything**
>
> A template can render a short answer if one exists; it cannot make an editor write one. Constraints have to live where the content is entered. The CMS is the only layer that can refuse to save a guide without an author.

## Signals become fields

Every signal described in [What is Answer Engine Optimization](/guides/what-is-answer-engine-optimization) maps to a field, a constraint and a downstream use.

| Signal | Field | Constraint | Generated from it |
| --- | --- | --- | --- |
| Direct answer | `short_answer` (textarea) | required, 420 characters (about 40–70 words) | Speakable block, JSON-LD `description`, `llms.txt` line |
| Scannable summary | `key_takeaways` (nestable `takeaway`) | max 6, 240 characters each | `ItemList` in JSON-LD, Markdown list |
| Question coverage | `faq` (nestable `faq_item`) | question + answer, 600 characters | `FAQPage` JSON-LD, FAQ section in HTML and Markdown |
| Verifiable sources | `sources` (nestable `source`) | `url` must match `^https?://` | `citation` in JSON-LD, numbered list in Markdown |
| Who wrote it | `author` (single story reference) | required, restricted to `author` stories | `Person` JSON-LD with `sameAs`, `rel="author"` link |
| When | `published_at`, `updated_at` (date) | date only | `datePublished`, `dateModified`, feed and sitemap dates |
| Entities | `related_terms` (multi story reference) | restricted to `glossary_term` | `about` as a `DefinedTerm` list, `keywords`, a "terms used" section |

### The short answer is a separate field, not the first paragraph

A first paragraph is subject to every editorial instinct that produces a warm-up sentence. A required textarea with a character limit gets written as an answer. Citable renders it in a `data-speakable="short-answer"` block and points the [Speakable](/glossary/speakable) selector at it.

### Author and related terms are relations, not strings

A free-text author field cannot produce a stable `Person` identifier. A relation to an `author` story gives every guide the same `@id`, bio and `sameAs` links. Related glossary terms work the same way: the term's `DefinedTerm` `@id` is reused in the guide's `about` property, so a crawler can connect the definition to every guide that relies on it.

### Sources are structured, not a paragraph of links

A `source` blok has `title`, `url`, `publisher` and `published`. The URL pattern check stops a bare domain from being saved as a citation, and the four fields are exactly what a `CreativeWork` citation needs.

## Citable's Storyblok schema

Storyblok distinguishes **content types** (root components that become stories) from **nestable** components (bloks that live inside a field of another component). Citable has five content types and a set of small nestables, defined once in TypeScript and pushed to the space with the Management API, so the CMS and the front-end types cannot drift apart.

### Content types

- **guide** — `title` (max 90), `subtitle` (max 200), `short_answer` (required, max 420), `category` and `difficulty` (single-option), `reading_time` (number, computed when empty), `author` (required single story reference), `cover` (asset), `key_takeaways` (bloks: `takeaway`, max 6), `body` (richtext, required), `faq` (bloks: `faq_item`), `sources` (bloks: `source`), `related_guides` and `related_terms` (multi story references), `seo_title` (max 70), `seo_description` (max 160), `published_at` and `updated_at` (date).
- **glossary\_term** — `term`, `definition` (required, max 320, emitted as `DefinedTerm`), `also_known_as`, `expanded` (richtext), `related_terms`, `related_guides`, `faq`, `seo_description`.
- **author** — `name`, `role`, `bio` (required, becomes the `Person` description), `avatar`, `website`, `links` (bloks: `social_link`, become `sameAs`).
- **page** — `title`, `seo_description`, `body` (bloks: `hero`, `feature_grid`, `guide_collection`, `glossary_preview`, `faq_section`, `principles`, `cta`, `prose_section`).
- **site\_config** — `site_name`, `tagline`, `description`, `nav`, `footer_links`, `ai_policy` (published verbatim in `llms.txt`).

### How a relation field is declared

Story references in Storyblok are `option` (single) or `options` (multiple) fields with `source: "internal_stories"`. Restricting them by content type and folder turns "a reference" into "an author".

```ts
const storyRef = (contentTypes: string[], folder: string, extra = {}) => ({
  type: "option",
  source: "internal_stories",
  use_uuid: true,
  filter_content_type: contentTypes,
  folder_slug: folder,
  ...extra,
});

// in the guide schema
author: storyRef(["author"], "authors/", { required: true }),
short_answer: {
  type: "textarea",
  required: true,
  max_length: 420,
  description: "The direct answer, 40–70 words. This is what answer engines quote.",
},
```

### Rich text with embedded bloks

The guide body is a richtext field. Storyblok stores rich text as JSON nodes (paragraphs, headings, lists, code blocks) rather than HTML, and nestable components can be inserted between them. The `@storyblok/richtext` documentation puts it plainly: components are embedded as `blok` nodes, each carrying an `attrs.body` array that the renderer resolves itself.

Citable uses that for the parts of a guide that need semantics a paragraph cannot carry: `callout` (variant, title, richtext body), `checklist` (a list of `check_item`), `stat` (value, label, source) and `code_block`. Because a callout is a component with fields, the Markdown exporter can render it as a blockquote, the plain-text extractor can count its words, and the JSON-LD builder can ignore it. None of them has to parse HTML.

## Generating every representation from the same fields

Each machine-readable output is then a pure function of a story.

### JSON-LD

Google's Article structured data documentation recommends `author`, `author.name`, `author.url`, `datePublished`, `dateModified`, `headline` and `image`. All of them are fields or derived from fields. The guide builder returns a `TechArticle` (a subtype of `Article` on [schema.org](https://schema.org/TechArticle)) together with `Person`, `FAQPage` and `BreadcrumbList` objects:

```ts
const article = {
  "@context": "https://schema.org",
  "@type": "TechArticle",
  headline: c.title,
  description: c.short_answer,
  datePublished: pubDate(guide),
  dateModified: modDate(guide),
  author: { "@id": `${authorUrl}#person` },
  speakable: { "@type": "SpeakableSpecification", cssSelector: ["[data-speakable='short-answer']"] },
  citation: c.sources.map((s) => ({ "@type": "CreativeWork", name: s.title, url: s.url })),
  about: terms.map((t) => ({ "@type": "DefinedTerm", "@id": `${termUrl(t)}#term`, name: t.content.term })),
};
```

### Markdown and content negotiation

The same story renders to Markdown: front matter with the canonical URL, author and both dates, then the short answer, takeaways, body, FAQ and numbered sources. A request for `/guides/<slug>.md`, or for `/guides/<slug>` with `Accept: text/markdown`, is rewritten to that renderer, and the response carries a `Link: <…>; rel="canonical"` header pointing back at the HTML page.

### llms.txt

The index follows the [llmstxt.org](https://llmstxt.org/) layout: an H1, a blockquote summary, then sections of `- [Title](url): description` links. Every description is a guide's `short_answer` or a term's `definition`, so the file is a faithful index of the site without anyone maintaining it:

```markdown
# Citable

> Citable is an open field guide to Answer Engine Optimization (AEO) …

## Guides: Technical

- [Modelling content for answer engines in a headless CMS](https://…/guides/modeling-content-for-answer-engines-in-a-headless-cms.md): A headless CMS content model …
```

The file's "Policy for AI systems" section is the `ai_policy` field of `site_config`.

## Wiring Storyblok to the site

### Seeding the space with the Management API

Citable's seed script pushes the component definitions and local content into a space. Components are created or updated by name and stories by `full_slug`, so it is idempotent. Relations need two passes: Storyblok assigns story UUIDs on creation, so the script first creates every story with relation fields emptied, then writes the relations with the assigned UUIDs. Storyblok's Management API documentation states a limit of 3 requests per second on the Starter plan and 6 on higher plans, so the script throttles and retries on `429`.

**50** — Maximum number of related stories the Content Delivery API resolves in one request with resolve\_relations. Keep relation lists short and avoid deep chains. (Source: [Storyblok: Retrieve multiple stories](https://www.storyblok.com/docs/api/content-delivery/v2/stories/retrieve-multiple-stories))

### Reading content, published or draft

The front end reads the Content Delivery API with `version=published` and a `resolve_relations` list of `component.field` pairs (`guide.author`, `guide.related_terms` and so on), then merges the returned `rels` array back into each story. Responses are cached with the tag `storyblok`.

Preview uses Next.js Draft Mode. The Storyblok visual editor loads the site in an iframe from a preview URL set under Settings → Visual Editor; Citable points it at `/api/draft?secret=…&slug=`, and Storyblok appends its `_storyblok` parameters. The handler enables Draft Mode and redirects to the story:

```ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const url = new URL(request.url);
  if (url.searchParams.get("secret") !== process.env.DRAFT_MODE_SECRET) {
    return new Response("Invalid token", { status: 401 });
  }
  (await draftMode()).enable();
  redirect(`/${url.searchParams.get("slug") ?? ""}`);
}
```

While Draft Mode is on, the content layer switches to `version=draft` and `cache: "no-store"`, so editors see unpublished changes immediately.

### Webhooks for revalidation

Storyblok's webhook documentation lists `published`, `unpublished`, `deleted` and `moved` story events, each posting a JSON body with `action`, `full_slug`, `story_id` and `space_id`. Citable registers one endpoint for publish and unpublish and invalidates everything tagged `storyblok`:

```ts
import { revalidatePath, revalidateTag } from "next/cache";

export async function POST(request: Request) {
  const url = new URL(request.url);
  if (url.searchParams.get("secret") !== process.env.REVALIDATE_SECRET) {
    return Response.json({ ok: false }, { status: 401 });
  }
  const payload = await request.json().catch(() => ({}));
  revalidateTag("storyblok", "max");
  revalidatePath("/", "layout");
  return Response.json({ ok: true, story: payload.full_slug ?? null });
}
```

Because `llms.txt`, `llms-full.txt`, the sitemap, the feed and every Markdown route read through the same tagged fetches, one publish event refreshes all of them. Storyblok's webhook documentation also describes a `webhook-signature` header that your endpoint can verify against a webhook secret; secrets are only available on paid plans, and that check is stronger than a query-string secret.

## Validating content before it ships

Field constraints catch the crude mistakes; a content check catches the ones that need counting. Citable runs a script over every story before it is seeded.

**Rules the content check enforces**

- [ ] short\_answer is 40–70 words
- [ ] at least three FAQ items, each question ending with a question mark
- [ ] at least two sources, every URL https
- [ ] author, related\_guides and related\_terms resolve to real stories
- [ ] body starts with an H2, has at least three H2 sections and no H1
- [ ] body is at least 700 words and every internal link resolves
- [ ] seo\_title at most 70 characters, seo\_description at most 160

The fixtures the check validates are the ones the seed script pushes, so a guide that passes locally renders correctly in the space. Because the model is data, it can be tested like data.

> **Tip: Where to go next**
>
> [Structured data for AI answers](/guides/structured-data-for-ai-answers) covers the JSON-LD types those fields feed in detail, and [llms.txt: what it is and how to write one](/guides/llms-txt-what-it-is-and-how-to-write-one) shows what the generated index should contain.

## Frequently asked questions

### Why should the short answer be its own field rather than the first paragraph?

Because a first paragraph is subject to every editorial instinct that produces a warm-up sentence, and nothing enforces its length. A required textarea with a character limit is written as an answer, can be rendered in a dedicated Speakable block, and can be reused unchanged as the JSON-LD description and the llms.txt entry for the page.

### Should the author be a text field or a relation to an author entry?

A relation. A free-text name cannot produce a stable schema.org Person identifier, a bio, or sameAs links, and it drifts in spelling across pages. A relation to an author content type gives every guide the same Person @id and lets the author page list its guides without a search.

### Can this approach be used with a CMS other than Storyblok?

Yes. The model needs only required fields, length limits, URL validation, references to other entries and nestable components. Contentful, Sanity, Strapi and Payload all offer those. The Storyblok-specific parts are the Management API seeding, the visual editor preview URL and the webhook payload shape, and each has an equivalent elsewhere.

### How does the site stay current after an editor publishes in Storyblok?

Storyblok sends a webhook on story publish and unpublish events. The site's endpoint verifies a secret, then invalidates every cached Content Delivery API response by tag. Because the HTML pages, Markdown routes, llms.txt, sitemap and feed all read through the same tagged fetches, one publish event refreshes every representation on its next request.

### Do embedded bloks in rich text make the content harder for machines to read?

No. Storyblok stores rich text as JSON nodes, and an embedded component is a typed blok node with named fields. The Markdown exporter renders a callout as a blockquote and a checklist as a list, and the plain-text extractor counts their words. Typed fields are easier to convert than HTML that has to be parsed and guessed at.

## Sources

1. [Management API](https://www.storyblok.com/docs/api/management) — Storyblok
2. [Retrieve multiple stories (Content Delivery API v2)](https://www.storyblok.com/docs/api/content-delivery/v2/stories/retrieve-multiple-stories) — Storyblok
3. [Webhooks](https://www.storyblok.com/docs/concepts/webhooks) — Storyblok
4. [@storyblok/richtext](https://www.storyblok.com/docs/packages/storyblok-richtext) — Storyblok
5. [Article (Article, NewsArticle, BlogPosting) structured data](https://developers.google.com/search/docs/appearance/structured-data/article) — Google Search Central (2025)
6. [draftMode](https://nextjs.org/docs/app/api-reference/functions/draft-mode) — Next.js documentation (Vercel) (2026)

## Related guides

- [Structured data for AI answers: the schema.org types that matter](https://citable.wiki/guides/structured-data-for-ai-answers)
- [llms.txt: what it is, how to write one, and whether it helps](https://citable.wiki/guides/llms-txt-what-it-is-and-how-to-write-one)
- [Serving Markdown to LLMs: content negotiation and .md endpoints](https://citable.wiki/guides/serving-markdown-to-llms)
- [What is Answer Engine Optimization (AEO)?](https://citable.wiki/guides/what-is-answer-engine-optimization)

---

Source: https://citable.wiki/guides/modeling-content-for-answer-engines-in-a-headless-cms
Author: Endrit Krasniqi
More: https://citable.wiki/llms.txt
