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.

How a story's relations get resolvedA request asks for a story with resolve_relations naming block and field; the API finds related uuids in those fields; within the inline limit it returns the related stories in the rels array or inline; above it returns rel_uuids, which the client fetches in chunks; the frontend maps uuids to stories before rendering.Requestresolve_relationsDelivery APIWithin limit?rels / inlinerel_uuidsClient fetchesin chunksMap uuid to storyyesno
Name exactly the fields to resolve, and let the client handle overflow.

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.

Choosing how to load related contentSeparate requests, resolve_relations with full stories, and resolve_relations with a trimmed list request compared by request count, response size, cache behaviour and complexity.ApproachRequestsResponse sizeComplexitySeparate request per relationmanysmall eachhigh, waterfallsresolve_relations, full storiesonelargelowTrimmed list + resolve_relationstwosmallmoderate
Resolving relations cuts requests; trimming what you resolve keeps responses small.

Implementation

Fetch the homepage with only the relations it renders, and resolve links as URLs:

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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 language parameter, 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.

Homepage API response sizeTotal size of Storyblok API responses needed to render the homepage with separate requests, with broad relation resolution, and with trimmed resolution.Separate requests (37)410 kilobytesBroad resolution (1)1100 kilobytesTrimmed resolution (1)96 kilobytes
Resolving only rendered fields gave one request and a small response.

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.field pairs each page renders.
  • Use resolve_level only where nested relations are displayed.
  • Exclude heavy fields from list requests.
  • Build a uuid map from rels and handle rel_uuids overflow.
  • 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.