- Introduction
- The naive approach and why it hurts
- The fix: model the entity as a Content Type
- The schema
- Rendering the same entity in different contexts
- Resolving references without over-fetching
- Conclusion
Introduction
Every Storyblok project I have worked on eventually runs into the same question: I have a piece of content that shows up in more than one place, looking different each time. How do I model it so I do not end up copying the same data all over the space?
Take a blog. An article appears on its own page with the full story: the body, images, code blocks, everything. The same article also shows up in a “featured posts” carousel on the homepage, this time as a small card with just a title, an excerpt, and a cover image. It appears again in a “related posts” strip at the bottom of other articles, and once more in a category listing. Same article, four different presentations.
The tempting first move is to build an articleCard block, drop it into the carousel, and type the title, excerpt, and cover image straight into it. It works immediately, so it feels right. Then you feature the same article in a second place, and a third. Now the same article lives in several places: its page and every card. When you rename the article, you are hunting through the whole space to update every copy, and you will miss one. This is a plain DRY violation, and it is the villain of this post.
The deeper lesson underneath the blog example is this: an article is data, and how it is displayed is presentation. Once you separate the two, the same entity can wear different clothes in every context without ever being duplicated.
The naive approach and why it hurts
It is worth being precise about how you end up duplicating data, because the mistake is subtle. You rarely set out to copy an article. You reach for a block.
Blocks in Storyblok are inline content. When you place a nestable block, you are creating a fresh, self-contained copy of its fields right there in the parent story. An articleCard block with title, excerpt, and coverImage fields feels reusable, but every placement is a new copy with no link back to a shared source. Two carousels featuring “the same” article actually hold two independent snapshots that drift apart the moment one is edited.
That is the tell: a block is a copy, not a reference. The instant you need the same content in two places and want it to stay in sync, a block is the wrong tool.
The fix: model the entity as a Content Type
An article is not a piece of layout. It is an entity with an identity, and in Storyblok entities are Content Types, not blocks.
So I create an article Content Type. It is routable and lives under its own path, for example /blog/one-entity-many-contexts, because every article has a page anyway. It holds two kinds of fields:
- Its key figures as data fields:
title,excerpt,coverImage,publishedAt. - A
contentfield of type Blocks, which holds the article body, exactly like thecontentfield on a regularpage.
The article is the page. There is no separate “article page” story pointing at the content, and no place where the body is duplicated. Everything an article owns lives in one story, in one place.
The rule I follow to decide what goes where:
The entity owns everything that is one-to-one with it. The context owns everything that varies by context.
The title, excerpt, cover image, and body are all one-to-one with the article, so they live on the article. The card layout varies by context (a featured carousel, a related-posts strip, a category listing all look different), so it is derived by each context from the article’s data, never stored on the article.
This pattern comes in two flavors, depending on whether the entity has a page of its own:
- Routable entity when it has a page. An article has its own URL, so it is a routable Content Type at its own path.
- Global folder entity when it does not. An
authorhas no page of its own but is referenced from every article they wrote, so it lives as a non-routable story in a dedicated folder such as_global/authorsand is referenced wherever it is needed.
Either way the principle is the same: model the entity once, reference it everywhere. An article references its author. A carousel references its articles. Nothing is copied.
The schema
Here is the whole structure. Four components carry the example: the article Content Type, the author it references, the articleCarousel block, and the page Content Type that hosts the carousel.
| Component | Kind | Field | Field Type | Purpose |
|---|---|---|---|---|
| article | Content Type | title | Text | Key figure |
| article | Content Type | excerpt | Textarea | Key figure |
| article | Content Type | coverImage | Asset | Key figure |
| article | Content Type | publishedAt | Date/Time | Key figure |
| article | Content Type | author | Multi-Options (References), max 1 | Points at an author story |
| article | Content Type | content | Blocks | Article body |
| author | Content Type | name, avatar, bio | Text, Asset, Textarea | Global, non-routable |
| articleCarousel | Nestable block | articles | Multi-Options (References) | Points at article stories |
| page | Content Type | body | Blocks | Hosts the carousel |
The articleCarousel holds no article data at all. Its articles field is a Multi-Options reference that stores the UUIDs of the article stories it points at. There is a single source of truth for every article, and every context borrows from it.
Rendering the same entity in different contexts
With one entity in place, each context simply picks the fields it needs:
- The article page renders the full story: the key figures, the resolved
author, and thecontentblocks. - The featured carousel renders a card from the key figures only:
coverImage,title,excerpt. - The related-posts strip renders the same card shape with a different heading.
- A category listing renders an even thinner row: just
titleandpublishedAt.
The author entity earns its keep the same way. It is rendered as a full byline with avatar and bio on the article page, and as a plain name in a compact “written by” line inside a card. One author story, two presentations, no duplication.
This is the payoff. When you add a fifth context tomorrow, you do not touch the article at all. You write a new presentation that reads the fields it cares about. The context decides which fields to render, and the entity never changes.
Resolving references without over-fetching
A reference field stores UUIDs, not the referenced content. To turn those UUIDs into real data you have to resolve them, and how you resolve them matters a lot for payload size.
The obvious way is resolve_relations. You pass the component and field you want resolved, in the form componentName.fieldName. On the article page, resolving the author is exactly right, because you render it and it is small:
import StoryblokClient from "storyblok-js-client";
const storyblok = new StoryblokClient({
accessToken: process.env.STORYBLOK_TOKEN,
});
const { data } = await storyblok.get(
"cdn/stories/blog/one-entity-many-contexts",
{
version: "published",
resolve_relations: ["article.author"],
},
);
Because author is a Multi-Options field capped at one, it resolves to an array with a single author object, so you read it as author[0] rather than as a bare object.
The same technique on a carousel is where it goes wrong. Resolving articleCarousel.articles inlines the entire referenced story, including the heavy content blocks. A carousel of ten articles pulls ten full article bodies just to render ten small cards, even though the cards need only three fields each. You are downloading the whole article ten times to show a thumbnail and an excerpt.
Storyblok also caps the number of relations it resolves per request, so leaning on resolve_relations for large lists is a trap on both payload and completeness.
The fix is to not resolve at all in list contexts. Instead, fetch exactly the articles you need, ordered, and strip the field you do not want:
const uuids = carousel.articles; // array of article UUIDs from the reference field
const { data } = await storyblok.get("cdn/stories", {
version: "published",
by_uuids_ordered: uuids.join(","),
excluding_fields: "content",
});
by_uuids_ordered returns the articles in the order the editor arranged them in the carousel, which a plain UUID lookup would not guarantee. excluding_fields: 'content' tells the CDN to leave the heavy Blocks field out of the response entirely, server-side. What comes back is just the key figures: exactly what a card needs and nothing more.
So the division of labor is:
- The article page fetches the full story,
contentincluded, because it renders it. - List contexts (carousel, related posts, category listing) fetch the articles by UUID with
excluding_fields: 'content', paying for two requests instead of one but keeping every payload lean.
Co-locating the body and the key figures on a single entity costs you nothing at read time, as long as you resolve deliberately.
Conclusion
The blog is just a vehicle. The pattern is the point: when a piece of content shows up in more than one place, model it once as an entity, reference it everywhere, and let each context choose which of its fields to render. Reach for a Content Type when you need identity and reuse, a routable one when the entity has a page and a global folder one when it does not, and a block only for genuinely inline, one-off layout.
Separate the data from its presentation and duplication simply has nowhere to live. One entity, many contexts.