Engineering

Structured output over prose: how we make LLMs safe to put in a CMS

Letting a model write into a production database is only safe if the database can reject what it produces. The validation layer is the whole design.

8 min read

Hero art direction: Terminal on a dark display showing a Zod validation error trace, shallow focus, no branding visible.

The failure mode nobody plans for

The obvious risk with generative content is that a model writes something wrong. The harder risk is that it writes something structurally wrong — a field that should be a string is an array, a block type that does not exist, a nested object missing a required key.

Wrong prose is a review problem. Wrong structure is a production incident, because it reaches the renderer and the renderer throws.

The rule: the schema is the interface

We never let a model write free text into a content field and hope. Every generation returns JSON validated against the same schema the CMS and the front end use. If it does not validate, it does not land.

Three things follow from that, and they are the whole design:

One schema, three consumers. The Zod schema that validates an editor's form submission is the schema that validates model output and the schema the renderer's types are derived from. There is no second definition to drift.

A closed registry. The model may only emit block types that exist. Given a fixed list of 38 with their prop schemas, "invent a new component" stops being a possible output rather than an unlikely one.

Repair, then surface. On a validation failure we feed the specific error back and retry, up to three times. If it still fails, the raw output and the exact error go to the human — not a silent fallback that quietly produces a worse page.

Put the taste rules in the validator

This is the part most teams miss. If your brand voice forbids the word "seamless", a style guide in a prompt is a suggestion. A validator that rejects the field is a constraint.

We encode the banned-phrase list, the "no exclamation marks" rule, and a check that CTA labels are not generic directly into the schema. A model that emits a button labelled "Learn more" gets a validation error naming the problem, and the repair loop fixes it before a human ever sees it.

The same rules then apply to human editors, which is the right outcome. The constraint was never really about the model.

Ledger every call

Every generation is recorded with its prompt template and version, the model, input and output token counts, computed cost, latency, retry count, and the user who asked. Without that you cannot answer "why did this page get written that way" or "what are we spending", and both questions arrive eventually.

Cost is computed at write time from a pinned rate card. A budget cap that moves with the exchange rate is not a cap.

Plan before generating

The step that most improves output quality is not a better prompt. It is asking for a structural plan first — the proposed block sequence with a one-line rationale each — and having a human approve or edit it before any copy is written.

Generic output is almost always a structural problem. A page that opens with a hero, then three paragraphs, then a CTA reads as generated because that shape carries no argument. Approving the shape first is what fixes it.

What we will not do

We do not auto-publish. Everything lands in review with a diff. The model is a drafting tool with a very strict output contract, and the last approval is a person's.

Questions we get

Follow-ups

01Why not just use a model's JSON mode?

JSON mode guarantees syntactically valid JSON, not a payload matching your schema. You still need the schema check. We use structured output as the transport and Zod as the gate, because only the second one knows that `ladderStage` must be one of three specific values.

02How often does validation actually fail?

With the block examples supplied as few-shots, first-attempt failures are uncommon and almost always in the same places: exceeding a max length, or a banned phrase. Nearly all are fixed on the first repair attempt. The repair loop exists for the tail, not the norm.

03Does this work with a headless CMS we already have?

If its content model is expressible as a schema you control, yes. If it stores rich text as opaque HTML, you lose the ability to validate structure, which is most of the benefit. That constraint is a large part of why we build custom CMSes for this.

Talk to the team that runs this on the floor

Send the date, the city and the headcount. We reply with numbers.

Was this useful?