Skip to content

engineering

A content model that survives its second year

8 min read

Atlas has three content primitives and no fourth. Entries are typed collections: an article, a service, an industry, an FAQ. Pages are an ordered list of blocks with SEO attached. Media is files. Everything on latellu.com is one of those three, and most of the modelling work on a project is deciding which one a given piece of content is.

That decision is cheap to make and expensive to change, which is a bad combination, because it means you make it fastest at the moment you understand the content least. This is what we have learned about making it well enough that the second year is not a migration project.

Entries and pages answer different questions

An entry has a shape. Every service has a title, a summary, a body, a list of deliverables, and a stack. The shape is the same for the third service as for the first, which is why a service can be rendered by a template, listed on an index, cross-linked from an industry page, and sorted by an order field. The editor is filling in a form, and the form is short because the design already decided what a service page looks like.

A page has no shape. The home page is a hero, then a services list, then a proof section, then a closing ask, and next quarter it may be four different things in a different order. Modelling that as a content type means inventing a field per section and a boolean per section to hide it, which is a page builder with worse ergonomics. Modelling it as an ordered list of blocks, each with a type, a position, and a data object, is honest about what it is.

The test we use: if you can describe the content without describing the layout, it is an entry. If you cannot say what it is without saying where it goes, it is a page.

Singletons sit awkwardly between the two, and we resolved it by not inventing a third thing. Site settings is a content type with exactly one entry, slug site-settings, holding the brand name, contact email, and the social links. It is a collection with one row. That is slightly silly and considerably better than a special case in the API, the SDK, the type generator, and the editor.

Column, block, or comma-separated string

Once something is an entry, the next question is per field, and there are three real answers.

A first-class field when the design reads it differently from the body. Service.situation is one line describing the buyer's problem in their words, and it exists as its own field because the home page card leads with it, the detail hero sets it as display type, and neither of those can dig it out of a paragraph. A field earns its place when at least two surfaces want it separately.

A comma-separated string when the values are a flat list of short labels that render as tags and nothing queries across them. Service.stack, Service.deliverables, Article.tags, and Service.industries are all CSV, read through one helper that splits, trims, and drops empties. This is a defensible choice and people are often uncomfortable with it, so here is the boundary: CSV stops working the moment an element needs a second attribute, or you need to sort by it, or you need to ask which entries contain a given value without loading them all. SiteSettings.socials is stored as label:url pairs in one string, which works because it is exactly two attributes and will never be three. When it becomes three, it becomes a content type.

A block when the content is a section rather than a fact. Blocks belong to pages. Putting a block-shaped thing on a content type is how a service page ends up with a hand-rolled page builder that only services can use.

References are slugs, and slugs are a promise

Cross-type references in our model are slug strings. Article.author holds an author slug, Article.category holds a category slug, Service.industries holds a CSV of industry slugs. The frontend builds a lookup map and joins in memory: fetch the authors, key them by slug, read the one you want.

At this size that is the right trade, and it has two costs that are easy to forget. Renaming a slug is a migration across every field that mentions it, and nothing tells you which fields those are. And a reference to a slug that does not exist resolves to undefined rather than erroring, which is why the service page filters its related industries down to the ones whose name actually resolved. If it did not, a deleted industry would render a link with an empty label pointing at a 404.

Both ways to be wrong

Over-modelling is the one engineers do. It looks like foresight: add the fields now, while the schema is easy to change. The cost lands somewhere else. Every optional field is a box an editor sees and has to decide about, and fourteen optional fields is a template with fourteen conditional branches, each of which has to look right when the field is empty and when it is full and when the three around it are empty. Nobody designs for that; they design for the filled state and the empty states rot. Then the field survives its purpose. Our Industry type carries an icon, put there when the header panels used icons. The panels became hairline lists, the index never picked it up, and the field is still in Atlas and still in the interface, documented as unrendered, because the payload still has it. That is the honest end state of a field added early: not a disaster, just a small permanent thing to explain.

Under-modelling is the one that gets defended as flexibility. One rich text body, editors can do anything. What actually happens is that the structure still exists, because the writer meant those three items to be a list and meant that sentence to be the summary, and now the structure is trapped inside HTML where the design cannot reach it. You cannot put the summary in a card. You cannot pull the first section into a sidebar. You cannot show the deliverables anywhere except in reading order, in the middle of the page, at body size.

Cutting one field at its headings

The move that has bought us the most room sits between those two failures. Service and industry bodies are a single richtext field, and the detail page splits that field at its h2 boundaries into sections of heading plus the HTML underneath. Each section then gets its own composed grid row, with the heading re-set as display type rather than as body copy. One field, a designed layout, and no page builder.

It works because it fails soft. A body with no h2 produces a single heading-less section, which the page renders as one plain column. The page checks whether it got real structure before switching layouts, so a half-written draft looks plain rather than broken. The layout is a bonus the content earns and never a requirement that can break the page. That constraint is what makes the trick safe to ship: an editor cannot get it wrong, only get less out of it.

Published is a state, not a boolean

Atlas entries carry a status of draft, published, or scheduled, and separately a published_at timestamp. Both facts are needed, and the reason is that a boolean cannot answer the questions people actually ask.

  • Scheduled is not draft and not published. It is content that is finished, approved, and waiting for a date. A boolean plus a date field gets you there, badly, and then someone has to remember which combination means what.
  • Unpublished is not the same as never published. An entry pulled from the site keeps its original publication date, so restoring it does not silently change its date, its position in a feed, or its URL.
  • The date is not the switch. An entry can be republished after an edit without moving its published_at, which is what stops a typo fix from reordering the blog index.

Having the API filter on state rather than the frontend is the other half. The list endpoint returns published entries only, so a draft is invisible to the site without a single line of frontend filtering, and there is no way to forget the filter on a new page.

One warning from our own build, because it is the kind of bug this shape invites. Atlas sends published_at, our internal type uses publishedAt, and our fallback fixtures were stored in the wire shape. Spreading a fixture straight into an entry produced publishedAt: undefined on every fallback entry, which meant article dates and RSS timestamps vanished whenever the API was unreachable. It never failed in normal operation, so it was only ever visible in the situation where you are already dealing with something else. Two names for one fact is a trap, and the fix is one explicit mapping function used by both paths rather than a spread.

When to give in and ship the page builder

The position above, that content types should have a shape and blocks belong to pages, is wrong for a specific team. If marketing ships landing pages weekly, each one different, and cannot wait on an engineer or a deploy, then a block library with twenty section types is correct and the inconsistency it produces is the price of the throughput. Arguing them into a rigid schema means they will build pages somewhere else, in a tool you do not control, with SEO and analytics you cannot see.

Three questions before adding a field, which is the whole article compressed: does the design read this separately from the body, will an editor fill it in on most entries, and can you delete it in a year without touching every template. Two yeses and a no is usually still worth it. One yes means it belongs in the body.

Working on something similar?

Tell us what you are running into. We are happy to compare notes.