Skip to content
Citable
TechnicalIntermediate · 9 min read

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.

Published

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

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

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.

Signals become fields

Every signal described in What is Answer Engine Optimization maps to a field, a constraint and a downstream use.

SignalFieldConstraintGenerated from it
Direct answershort_answer (textarea)required, 420 characters (about 40–70 words)Speakable block, JSON-LD description, llms.txt line
Scannable summarykey_takeaways (nestable takeaway)max 6, 240 characters eachItemList in JSON-LD, Markdown list
Question coveragefaq (nestable faq_item)question + answer, 600 charactersFAQPage JSON-LD, FAQ section in HTML and Markdown
Verifiable sourcessources (nestable source)url must match ^https?://citation in JSON-LD, numbered list in Markdown
Who wrote itauthor (single story reference)required, restricted to author storiesPerson JSON-LD with sameAs, rel="author" link
Whenpublished_at, updated_at (date)date onlydatePublished, dateModified, feed and sitemap dates
Entitiesrelated_terms (multi story reference)restricted to glossary_termabout 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 selector at it.

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.

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

  • guidetitle (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_termterm, definition (required, max 320, emitted as DefinedTerm), also_known_as, expanded (richtext), related_terms, related_guides, faq, seo_description.
  • authorname, role, bio (required, becomes the Person description), avatar, website, links (bloks: social_link, become sameAs).
  • pagetitle, seo_description, body (bloks: hero, feature_grid, guide_collection, glossary_preview, faq_section, principles, cta, prose_section).
  • site_configsite_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) 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 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

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.

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. [1]
  2. [2]
  3. [3]
    Webhooks

    Storyblok

  4. [4]
  5. [5]
  6. [6]
    draftMode

    Next.js documentation (Vercel) · 2026

Terms used in this guide

Headless CMS
A headless CMS is a content management system that stores content as structured fields and delivers it over an API, leaving the presentation layer — website, app, feed, Markdown export — to be built separately. For answer engines it matters because the same fields that render the page can also generate JSON-LD, Markdown and llms.txt without the outputs drifting apart.
Structured data
Structured data, in the web context, is machine-readable markup embedded in a page that states explicitly what the page contains — an article, its author, a publication date, a question and its answer — using a shared vocabulary such as Schema.org. It is written as JSON-LD, Microdata or RDFa and lets crawlers and answer engines identify entities and relationships without inferring them from prose.
JSON-LD
JSON-LD (JSON for Linking Data) is a W3C standard for expressing linked data as ordinary JSON. On the web it is the format Google recommends for Schema.org structured data: a single script block of type application/ld+json that describes the page's entities — article, author, dates, FAQ, definitions — without touching the visible HTML.
Speakable
Speakable is a schema.org property (with the SpeakableSpecification type) that identifies, by CSS selector or XPath, which parts of a page are best suited to be read aloud or quoted verbatim — typically a headline and a short summary. It was introduced for voice assistants and remains the most direct way to tell a machine which passage is the answer.
DefinedTerm
DefinedTerm is a Schema.org type for a word, name, acronym or phrase that has a formal definition. It carries a name, a description that holds the definition, an optional termCode, and an inDefinedTermSet link to the DefinedTermSet — the glossary or classification — it belongs to. It is the natural markup for a glossary entry and tells a machine that a passage is a definition.

Written by

Front-end engineer & author of Citable

Endrit Krasniqi is a front-end engineer who builds content platforms with React, Next.js, Nuxt and headless CMSs such as Storyblok. He writes Citable to document, with working code, how websites can be structured so that search engines, answer engines and large language models quote them accurately.

All guides
  • Structured data

    Structured data for AI answers: the schema.org types that matter

    Structured data for AI answers is JSON-LD that labels what a page contains: Article or TechArticle for provenance (author, datePublished, dateModified, citation), FAQPage for question–answer pairs, DefinedTerm for definitions, Person with sameAs for authors, BreadcrumbList and WebSite for context, and SpeakableSpecification for the direct answer. Google requires no markup for AI features; its value is removing ambiguity, so generate it from the CMS fields that render the visible text.

    9 min read

  • Technical

    llms.txt: what it is, how to write one, and whether it helps

    llms.txt is a proposed convention, published by Jeremy Howard of Answer.AI in September 2024, for a Markdown file at /llms.txt that gives language models a curated index of a site: an H1 title, a blockquote summary and H2 sections of links with one-line descriptions. No major AI provider has confirmed reading it, but it costs almost nothing to generate from a CMS and is widely published by documentation sites.

    8 min read

  • Technical

    Serving Markdown to LLMs: content negotiation and .md endpoints

    Serving Markdown to LLMs means publishing a plain-text representation of each page next to the HTML one, so that AI crawlers and agents can read it without rendering JavaScript or spending tokens on markup. There are two delivery patterns: a .md suffix on the canonical URL, and content negotiation on the Accept: text/markdown request header, with Vary: Accept and a Link rel=canonical header pointing back to the HTML page.

    10 min read