Resolving Storyblok Relations and Links Efficiently
Within Storyblok Visual Editor Integration, this guide covers fetching stories together with the stories they reference, such as an article’s author or a landing page’s featured products, and turning Storyblok’s link fields into routes. It explains how resolve_relations and resolve_links work, where resolved stories appear in the response, what happens when a request exceeds the number of relations the API resolves inline, and how to keep responses small on list pages.
In Storyblok, a relation is a field that stores the uuid of another story, typically a single-option or multi-option field whose source is stories. By default the Content Delivery API returns just those uuids, and the frontend would need another request per related story. Resolving relations inlines the related stories into the response instead, in one request. Link fields, which editors use for buttons and navigation, store a story id and a cached URL, and resolving links returns their current slugs, so renamed pages do not produce broken links.
The Problem
A recipe site’s homepage showed twelve recipe teasers, each with its author and category. The frontend fetched the homepage story, then each recipe, then each recipe’s author and category: 37 requests for one page, most of them uncached in preview. Builds slowed down as the site grew and occasionally hit rate limits. When the team switched to resolve_relations with a broad list of fields, the homepage response grew to more than a megabyte, because full recipe stories, with their complete ingredient lists and steps, were inlined only to display a title and an image.
How Relation Resolution Works
Name block and field. resolve_relations takes a comma-separated list of component.field pairs, such as recipe-teaser-list.recipes,recipe.author. Only fields listed are resolved; the rest stay as uuids.
Where resolved stories go. For single stories, resolved stories replace the uuids inline. For requests that return lists, resolved stories are returned once in a separate rels array and the frontend, or the official client, maps uuids to them. That avoids repeating the same author in every recipe.
Limits. The API resolves a limited number of relations inline per request. Above that limit, the response contains the uuids in rel_uuids, and the official JavaScript client fetches those stories in chunks and merges them. Custom clients must do the same.
Nesting. Resolution is one level deep by default. A recipe teaser list resolving recipes does not also resolve each recipe’s author unless you list that field too, and deeper levels need resolve_level.
Links. resolve_links=url returns the current full slug of linked stories, and resolve_links=story returns the linked stories themselves; for navigation, the URL form is almost always enough.
Implementation
Fetch the homepage with only the relations it renders, and resolve links as URLs:
// lib/home.ts
import { getStoryblokApi } from "@/lib/storyblok";
export async function getHome(version: "draft" | "published") {
const { data } = await getStoryblokApi().get("cdn/stories/home", {
version,
resolve_relations: ["recipe-teaser-list.recipes", "recipe.author", "recipe.category"],
resolve_level: 2,
resolve_links: "url",
});
return data.story;
}
For list pages, request the stories list with only the fields the teaser needs and resolve authors once through rels:
// lib/recipes.ts
type Story<C> = { uuid: string; full_slug: string; name: string; content: C };
type RecipeTeaser = { title: string; image?: { filename: string; alt?: string }; author?: string };
type Author = { name: string };
export async function listRecipes(page = 1, version: "draft" | "published" = "published") {
const { data } = await getStoryblokApi().get("cdn/stories", {
version,
starts_with: "recipes/",
content_type: "recipe",
excluding_fields: "ingredients,steps,body",
resolve_relations: ["recipe.author"],
per_page: 24,
page,
sort_by: "first_published_at:desc",
});
const authors = new Map<string, Story<Author>>((data.rels ?? []).map((r: Story<Author>) => [r.uuid, r]));
return (data.stories as Story<RecipeTeaser>[]).map((s) => ({
href: `/${s.full_slug}`,
title: s.content.title,
image: s.content.image,
author: s.content.author ? authors.get(s.content.author)?.content.name : undefined,
}));
}
Map link fields to routes in one function, so every button and menu entry uses the same rules:
// lib/links.ts
type SbLink = { linktype: "story" | "url" | "email" | "asset"; url?: string; cached_url?: string; story?: { full_slug: string }; anchor?: string };
export function hrefFor(link?: SbLink): string | undefined {
if (!link) return undefined;
switch (link.linktype) {
case "story": {
const slug = link.story?.full_slug ?? link.cached_url;
return slug ? `/${slug.replace(/^home$/, "")}${link.anchor ? `#${link.anchor}` : ""}` : undefined;
}
case "email": return link.url ? `mailto:${link.url}` : undefined;
default: return link.url || link.cached_url || undefined;
}
}
Handling rel_uuids in custom clients
If you use fetch directly rather than the official client, check each response for rel_uuids. When present, request the listed stories with by_uuids in chunks, with the same version and cache version, merge them into your uuid map, and only then render. Log when it happens: frequent overflow means the page resolves more than it should, and a separate, trimmed request for the related list is usually better.
Relations in draft mode
In draft mode, resolved relations return the draft versions of related stories, so previews show unpublished authors and categories correctly. The bridge’s input events need the same list of relations passed to the bridge, or live updates show uuids instead of resolved content until the next full refresh.
Configuration Reference
| Parameter | Recommendation | Why |
|---|---|---|
resolve_relations |
exact component.field pairs the page renders |
Small responses, no surprises. |
resolve_level |
2 only where nested relations are rendered | Depth costs size. |
excluding_fields |
heavy fields on list requests | Teasers need a few fields. |
rels |
build a uuid map once per response | Shared relations sent once. |
rel_uuids |
fetch in chunks, log occurrences | Overflow handled and visible. |
resolve_links |
url for navigation and buttons |
Current slugs, small payload. |
Gotchas & Edge Cases
- Deleted or unpublished relations. A uuid pointing to an unpublished story resolves to nothing in the published version. Filter missing relations out before rendering.
- Field names change. Renaming a relation field in the schema silently stops its resolution until the parameter is updated; generated types and a contract test catch this.
- Same story, several fields. A story referenced from two fields is resolved once in
rels; the uuid map handles both. - Links to the home story. The home story’s slug is often
home; map it to/in the link helper. - Translated slugs. With field-level translation and translated slugs, pass the
languageparameter, or resolved links point to default-language slugs.
Worked Example
The recipe site replaced its request waterfall with one homepage request resolving exactly three relation fields, and changed listing pages to trimmed list requests with authors resolved through rels. The homepage went from 37 requests to one, and its response shrank from 1.1 MB, in the broad resolution attempt, to 96 KB, because recipes arrived without ingredients and steps. Builds became faster and rate-limit errors disappeared from the logs.
Designing Models for Efficient Resolution
Resolution cost starts in the content model. When a page needs only a handful of fields from a related story, consider whether those fields belong in the relation at all: a teaser block with its own title and image override is often better than resolving the full target story, and gives editors control over how the teaser looks. Keep heavy content, such as long ingredient lists or rich bodies, in fields that list requests can exclude. Avoid chains of relations for things the page shows together; if every recipe teaser needs the author’s name, a denormalized author name field kept in sync by a small script may be simpler than two levels of resolution, although it adds a sync job. Choose deliberately, and document which relations each page type resolves next to the fetch helpers, so the next developer does not add a broad resolution list out of caution.
Rollout Checklist
- List exactly the
component.fieldpairs each page renders. - Use
resolve_levelonly where nested relations are displayed. - Exclude heavy fields from list requests.
- Build a uuid map from
relsand handlerel_uuidsoverflow. - Resolve links as URLs and map them to routes in one helper.
- Pass the same relation list to the bridge for live previews.
Frequently Asked Questions
Should I use GraphQL to avoid this?
Storyblok’s GraphQL API resolves relations through the query shape, which suits some teams. With REST, careful resolution parameters achieve the same result.
Does resolving relations affect caching?
Responses are cached per full URL, including the parameters, so consistent parameters per page type keep cache hit rates high.
Why is my relation still a uuid?
The field is not listed in resolve_relations, the component name differs, or the related story is unpublished in the version you requested.
Can I resolve relations inside rich text blocks?
Yes, blocks embedded in rich text are resolved like other blocks when their component and field are listed.
How do I find which pages resolve too much?
Log the response size and the presence of rel_uuids per page type during builds. The largest responses point to resolution lists that include fields the page never renders.