Deep Dive: How to Represent Your Block Library in Swift

Storyblok is the first headless CMS that works for developers & marketers alike.

Storyblok’s official Swift Content Delivery API Client takes full advantage of Swift’s type system, so you can define your block library as a type-safe schema right in your code. Here’s the default block library of every new space, written in Swift:

BlockLibrary.swift
import StoryblokClient

@BlockLibrary
enum Block {
    case feature(name: String)
    case grid(columns: [Block])
    case page(body: [Block])
    case teaser(headline: String)
}
Hint:

Want to follow along in Xcode? Set up a playground before we dive in:

  1. Select Create New Project → iOS → App Playground and enter a name.
  2. Open File → Add Package Dependencies and enter https://github.com/storyblok/storyblok-swift.git.
  3. Paste the snippet above into a new file named BlockLibrary.swift.
  4. Add a second file named Playground.swift with the following content:
Playground.swift
import Playgrounds
import StoryblokClient

#Playground {
    // REPL: append each code snippet from this article here to run it
}

We define a block library as an enum (by convention named Block) and apply the @BlockLibrary macro from the StoryblokClient module.

Each enumeration case represents a block from your library: the case name matches the block’s technical name, and the associated values define the block’s fields. As case page(body: [Block]) shows, an associated value can be a [Block] array to represent nested blocks.

Hint:

The @BlockLibrary macro automatically conforms Block to the Decodable protocol, so you can choose which properties to decode with CodingKeys whenever you need to. For example, here’s how to map a case name to a block’s technical name:

@BlockLibrary
enum Block {
    case featuredArticle(name: String)

    enum CodingKeys: String, CodingKey {
        case featuredArticle = "featured-article"
    }
}

Put the block library to work

Now that we’ve defined a block library, what can we do with it? For starters, we can initialize a StoryblokClient:

let client = StoryblokClient(
    library: Block.self,
    accessToken: "YOUR_ACCESS_TOKEN",
    version: .draft,
    region: .eu // Choose the region of your space.
)
Hint:

Following along in Xcode? Connect the client to a space:

  1. Log in to Storyblok and create a new space.
  2. Copy the preview access token from the quickstart or from Settings → Access Tokens.
  3. Replace YOUR_ACCESS_TOKEN in the snippet above with the preview token.

Then we can fetch a story with the client:

let story: Story<Block>? = try await client.story("home")
    .last()
    .values
    .first { @Sendable _ in true }

The StoryblokClient returns Combine publishers that emit responses first from the cache, then from the network. That’s why we chain last(), values, and first() to get a fresh copy of the home story.

Now we have a story instance with a content property of type Block, which we can match with a switch statement:

let value: Any = switch story!.content {
    case .page(let body): body
    case .feature(let name): name
    case .teaser(let headline): headline
    case .grid(let columns): columns
}

Because we fetched the home story and know its content type is page, we can narrow the match down to this:

if case .page(let body) = story!.content {
    body
}

Promote blocks to concrete types

Non-exhaustive conditional statements like the one above get clunky in practice. Instead, we can change our Block definition and promote blocks to concrete types of their own:

BlockLibrary.swift
import StoryblokClient

@BlockLibrary
enum Block {
    case feature(name: String)
    case feature(Feature)
    case grid(columns: [Block])
    case grid(columns: [Feature])
    case page(body: [Block])
    case page(Page)
    case teaser(headline: String)

    struct Feature {
        let name: String
    }

    struct Page {
        let body: [Block]
    }
}

Now our feature and page blocks are nested structs, which can replace Block as a type annotation wherever we know which block to expect.

For example, we changed case grid(columns: [Block]) to case grid(columns: [Feature]) because we know the grid columns only contain nested feature blocks. In practice, you’d also restrict which nestable blocks users can insert to enforce this in the space.

The story fetch from earlier gets cleaner too:

let story: Story<Block>? = try await client.story("home")
let story: Story<Block.Page>? = try await client.story("home")
    .last()
    .values
    .first { @Sendable _ in true }

Now story.content is a Block.Page, and we no longer need a conditional statement to access story.content.body.

Concrete block types also give you type-safe queries when you fetch multiple stories:

let stories = try await client
    .stories { @Sendable (q: inout StoriesQuery<Block.Feature>) in
        q.sort(by: \.name, .ascending)
        q.filter { $0.name.like("*space*") }
    }
    .last()
    .values
    .first { @Sendable _ in true }

Typing the query as Block.Feature implicitly sets its content type to feature, so we can sort and filter on its fields in a type-safe way.

Without concrete types, we’d have to pass the raw query parameters that the retrieve multiple stories endpoint accepts:

let stories = try await client
    .stories { @Sendable (q: inout StoriesQuery<Block>) in
        q.parameter("content_type", "feature")
        q.parameter("sort_by", "content.name:asc")
        q.parameter("filter_query[name][like]", "*space*")
    }
    .last()
    .values
    .first { @Sendable _ in true }

Summary

Promoting a block to a nested struct lets you use it as a type annotation, and that brings lots of type-safe goodness.

As a rule of thumb, promote a block to a concrete type once you find yourself writing a non-exhaustive conditional statement like this one:

if case .feature(let name) = content {
    // do something with name here
}

Represent fields in Swift

So far, our block library only contains text (String) and nested blocks ([Block]). Here’s how to define the other field types:

Field type

Swift type

Asset

Field.Asset

Multi-Assets

[Field.Asset]

Blocks

[Block]

Boolean

Bool

Date/Time

Date, String

Link

Field.Link

Number

Int, Double, Float, Decimal, String

Single-Option (stories source)

UUID, Story<T>

Single-Option (other sources)

String

Multi-Options (stories source)

[UUID], [Story<T>]

Multi-Options (other sources)

[String]

References

[UUID], [Story<T>]

Rich text

RichText<Block>

Text, Textarea, Markdown

String

Use an optional type (for example, String?) for fields that aren’t required. You can also use a custom type for any field, such as Plugin or Table, as long as it conforms to Decodable.

Represent story relations as UUID

As the table shows, References, Single-Option, and Multi-Options fields can link to other stories. Let’s change our block library again and define a story relation as a UUID:

BlockLibrary.swift
import Foundation
import StoryblokClient

@BlockLibrary
enum Block {
    case feature(Feature)
    case grid(columns: [Feature])
    case page(Page)
    case teaser(headline: String)

    struct Feature {
        let name: String
    }

    struct Page {
        let body: [Block]
        let related: UUID
    }
}
Hint:

Following along in Xcode? Add the field to your space:

  1. In your space, open Content → Home → Edit Component (⚙️).
  2. Add a new Single-Option field named related, set Source to Stories, and select Save.
  3. In the new Related field, select Choose an option…, pick Home, and select Save.
  4. Save the Home story to store the changes as a draft.

Now, when we fetch the home story, story!.content.related contains the UUID of the referenced story. In our case, that’s the home story’s own UUID, since we set the reference to itself.

Represent story relations as Story<T>

The big drawback of UUID relations: you need another fetch to get the related story’s content. To get everything in a single fetch, we can nest the story in our block library:

struct Page {
    let body: [Block]
    let related: UUID
    let related: Story<Page>
}

This makes the fetch resolve the story relation, so you receive the full story object in the response. In our case, though, the fetch fails: the reference points to itself, and Swift has a limitation here—value types can’t represent circular relationships.

Fortunately, the fix is small:

struct Page {
    let body: [Block]
    let related: Story<Page>
    let related: Story<Page>?
}

With an optional nested story, a circular relation only resolves to the depth set by resolveLevel when you fetch the story, which defaults to 1. In practice, that means:

story!.content.related == nil                  // false
story!.content.related!.content.related == nil // true

Optional nested stories also protect you from a limit of the Content Delivery API: a single request resolves up to 50 stories. Go over 50, and the API resolves none of them: it returns their UUIDs in a rel_uuids array instead. A fetch with non-optional relations then fails, while one with optional relations succeeds, with every relation set to nil. That’s why UUID beats Story<T>? when you expect more than 50 relations.

Summary

To get the best results with story relations, follow these guidelines:

  • Use Story<T> for required Single-Option fields that can’t contain circular relations (consider restricting the content types in the field configuration).
  • Use Story<T>? for Single-Option fields that aren’t required or might contain circular relations.
  • Use [Story<T>] for References and Multi-Options fields with a maximum below 50 that can’t contain circular relations (consider restricting the content types in the field configuration).
  • Use [Story<T>?] for References and Multi-Options fields that might contain circular relations.
  • Use [UUID] for References and Multi-Options fields without a maximum (or with one above 50), and retrieve the stories in a follow-up request by their UUIDs.

Finally, remember that the 50-story limit applies per request, so consider the total number of potential story relations across all fields.

Author

Nick Bransby-Williams

Nicholas Bransby-Williams

Nicholas is Storyblok's resident expert on all things mobile. Starting his career in mobile software development with technologies that predated the duopoly of iOS and Android, he has been working with both ever since.