Overview
This integration guide shows you how to how to update your existing project to:- install the ButterCMS package
- instantiate ButterCMS
- create components to fetch and display each of the three ButterCMS content types: Pages, Collections, and Blog Posts.
In order for the snippets to work, you’ll need to setup your dashboard content schemas inside of ButterCMS first.
Installation
Swift Package Manager (recommended)
Add the package through Xcode:- Open Xcode → File → Add Package
- Search for
buttercms-swift - Select the package and adjust dependency version if needed
- Click Add Package
CocoaPods
Add to yourPodfile:
target 'YourAppName' do
use_frameworks!
pod 'ButterCMSSDK', '~> 1.0.7'
end
pod install
Configuration
Add your API token to your app’s configuration:// Config.swift
enum Config {
static let butterCMSToken = ProcessInfo.processInfo.environment["BUTTERCMS_API_TOKEN"] ?? "your_api_token"
}
Initialize the client
Import and initialize the ButterCMS SDK:import ButterCMSSDK
// Initialize with your API token
let butterClient = ButterCMSClient(apiKey: Config.butterCMSToken)
Basic usage
Fetch a Page
// Async/Await (iOS 15+)
do {
let response = try await butterClient.getPage(pageType: "landing-page", pageSlug: "home")
let fields = response.data.fields
print("Headline: \(fields["headline"])")
} catch {
print("Error fetching page: \(error)")
}
// Completion Handler
butterClient.getPage(pageType: "landing-page", pageSlug: "home") { result in
switch result {
case .success(let response):
let fields = response.data.fields
print("Headline: \(fields["headline"])")
case .failure(let error):
print("Error: \(error)")
}
}
Fetch collection items
do {
let response = try await butterClient.getCollection(collectionSlug: "brands")
let items = response.data
for item in items {
print("Brand: \(item.fields["name"])")
}
} catch {
print("Error fetching collection: \(error)")
}
Fetch Blog Posts
- Completion Handler
- Completion Handlers (iOS 13+)
do {
let response = try await butterClient.getPosts(page: 1, pageSize: 10)
let posts = response.data
for post in posts {
print("Title: \(post.title)")
print("Author: \(post.author.firstName) \(post.author.lastName)")
}
} catch {
print("Error fetching posts: \(error)
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize))
]
return try await request(endpoint: "posts/", queryItems: queryItems)
}
func getPost(slug: String) async throws -> PostResponse {
return try await request(endpoint: "posts/\(slug)/")
}
}
enum ButterCMSError: Error {
case requestFailed
case notFound
}
// ButterCMS.swift
import Foundation
class ButterCMS {
static let shared = ButterCMS()
private let baseURL = "https://api.buttercms.com/v2"
private let apiToken: String
private init() {
self.apiToken = Config.butterCMSToken
}
private func request<T: Decodable>(
endpoint: String,
queryItems: [URLQueryItem] = [],
completion: @escaping (Result<T, Error>) -> Void
) {
var components = URLComponents(string: "\(baseURL)/\(endpoint)")!
var items = queryItems
items.append(URLQueryItem(name: "auth_token", value: apiToken))
components.queryItems = items
URLSession.shared.dataTask(with: components.url!) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(ButterCMSError.requestFailed))
return
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let result = try decoder.decode(T.self, from: data)
completion(.success(result))
} catch {
completion(.failure(error))
}
}.resume()
}
func getPage(pageType: String, slug: String, completion: @escaping (Result<PageResponse, Error>) -> Void) {
request(endpoint: "pages/\(pageType)/\(slug)/", completion: completion)
}
func getPosts(page: Int = 1, pageSize: Int = 10, completion: @escaping (Result<PostsResponse, Error>) -> Void) {
let queryItems = [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize))
]
request(endpoint: "posts/", queryItems: queryItems, completion: completion)
}
}
For complete API documentation including all available endpoints and parameters, see the REST API Reference.
Models
// Models.swift
import Foundation
struct PageResponse: Codable {
let data: PageData
}
struct PageData: Codable {
let slug: String
let fields: [String: AnyCodable]
}
struct CollectionResponse: Codable {
let data: [String: [AnyCodable]]
}
struct PostsResponse: Codable {
let data: [Post]
let meta: Meta
}
struct PostResponse: Codable {
let data: Post
}
struct Post: Codable, Identifiable {
var id: String { slug }
let slug: String
let title: String
let body: String
let summary: String
let published: String
let featuredImage: String?
let author: Author
let categories: [Category]
let tags: [Tag]
}
struct Author: Codable {
let firstName: String
let lastName: String
let email: String?
let bio: String?
}
struct Category: Codable {
let name: String
let slug: String
}
struct Tag: Codable {
let name: String
let slug: String
}
struct Meta: Codable {
let nextPage: Int?
let previousPage: Int?
let count: Int
}
// AnyCodable helper for dynamic content
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
value = string
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map { $0.value }
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues { $0.value }
} else {
value = NSNull()
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let string = value as? String {
try container.encode(string)
} else if let int = value as? Int {
try container.encode(int)
} else if let double = value as? Double {
try container.encode(double)
} else if let bool = value as? Bool {
try container.encode(bool)
} else {
try container.encodeNil()
}
}
}
Pages
- SwiftUI
- UIKit
// LandingPageView.swift
import SwiftUI
struct LandingPageView: View {
let slug: String
@State private var page: PageData?
@State private var isLoading = true
@State private var error: Error?
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let error = error {
VStack {
Text("Error loading page")
Text(error.localizedDescription)
.font(.caption)
.foregroundColor(.secondary)
}
} else if let page = page {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let headline = page.fields["headline"]?.value as? String {
Text(headline)
.font(.largeTitle)
.fontWeight(.bold)
}
if let subheadline = page.fields["subheadline"]?.value as? String {
Text(subheadline)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let imageURL = page.fields["hero_image"]?.value as? String,
let url = URL(string: imageURL) {
AsyncImage(url: url) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(8)
} placeholder: {
ProgressView()
}
}
if let body = page.fields["body"]?.value as? String {
HTMLTextView(html: body)
}
}
.padding()
}
}
}
.task {
await loadPage()
}
}
private func loadPage() async {
do {
let response = try await ButterCMS.shared.getPage(pageType: "landing-page", slug: slug)
page = response.data
} catch {
self.error = error
}
isLoading = false
}
}
// LandingPageViewController.swift
import UIKit
class LandingPageViewController: UIViewController {
private let slug: String
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
init(slug: String) {
self.slug = slug
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
loadPage()
}
private func setupUI() {
view.backgroundColor = .systemBackground
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentStack.translatesAutoresizingMaskIntoConstraints = false
contentStack.axis = .vertical
contentStack.spacing = 16
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 16),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
])
}
private func loadPage() {
Task {
do {
let response = try await ButterCMS.shared.getPage(pageType: "landing-page", slug: slug)
await MainActor.run {
displayPage(response.data)
}
} catch {
print("Error: \(error)")
}
}
}
private func displayPage(_ page: PageData) {
if let headline = page.fields["headline"]?.value as? String {
let label = UILabel()
label.text = headline
label.font = .preferredFont(forTextStyle: .largeTitle)
label.numberOfLines = 0
contentStack.addArrangedSubview(label)
}
// Add more fields...
}
}
Collections
// BrandsView.swift
import SwiftUI
struct BrandsView: View {
@State private var brands: [[String: Any]] = []
@State private var isLoading = true
var body: some View {
NavigationView {
Group {
if isLoading {
ProgressView()
} else {
List(brands.indices, id: \.self) { index in
let brand = brands[index]
HStack {
if let logoURL = brand["logo"] as? String,
let url = URL(string: logoURL) {
AsyncImage(url: url) { image in
image.resizable().frame(width: 50, height: 50)
} placeholder: {
ProgressView()
}
}
Text(brand["name"] as? String ?? "")
.font(.headline)
}
}
}
}
.navigationTitle("Our Brands")
.task {
await loadBrands()
}
}
}
private func loadBrands() async {
do {
let response = try await ButterCMS.shared.getCollection(keys: ["brands"])
if let brandsData = response.data["brands"] {
brands = brandsData.map { $0.value as? [String: Any] ?? [:] }
}
} catch {
print("Error: \(error)")
}
isLoading = false
}
}
Dynamic components
Component views
// Components/HeroView.swift
import SwiftUI
struct HeroComponentView: View {
let headline: String
let subheadline: String
let image: String?
let buttonLabel: String?
let buttonUrl: String?
var body: some View {
VStack(spacing: 16) {
Text(headline)
.font(.title)
.fontWeight(.bold)
Text(subheadline)
.font(.body)
.foregroundColor(.secondary)
if let buttonLabel = buttonLabel {
Button(buttonLabel) {
// Handle navigation
}
.buttonStyle(.borderedProminent)
}
if let imageURL = image, let url = URL(string: imageURL) {
AsyncImage(url: url) { image in
image.resizable().aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}
}
}
.padding()
}
}
Component renderer
// ComponentRenderer.swift
import SwiftUI
struct ComponentRenderer: View {
let components: [[String: Any]]
var body: some View {
ForEach(components.indices, id: \.self) { index in
renderComponent(components[index])
}
}
@ViewBuilder
private func renderComponent(_ component: [String: Any]) -> some View {
let type = component["type"] as? String ?? ""
let fields = component["fields"] as? [String: Any] ?? [:]
switch type {
case "hero":
HeroComponentView(
headline: fields["headline"] as? String ?? "",
subheadline: fields["subheadline"] as? String ?? "",
image: fields["image"] as? String,
buttonLabel: fields["button_label"] as? String,
buttonUrl: fields["button_url"] as? String
)
default:
EmptyView()
}
}
}
Blog
- Blog List
- Single Blog Post
// BlogListView.swift
import SwiftUI
struct BlogListView: View {
@State private var posts: [Post] = []
@State private var isLoading = true
@State private var currentPage = 1
@State private var hasNextPage = true
var body: some View {
NavigationView {
Group {
if isLoading && posts.isEmpty {
ProgressView()
} else {
List {
ForEach(posts) { post in
NavigationLink(destination: BlogPostView(slug: post.slug)) {
VStack(alignment: .leading, spacing: 4) {
Text(post.title)
.font(.headline)
Text("By \(post.author.firstName) \(post.author.lastName)")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
if hasNextPage {
Button("Load More") {
Task { await loadMorePosts() }
}
.frame(maxWidth: .infinity)
}
}
}
}
.navigationTitle("Blog")
.task {
await loadPosts()
}
}
}
private func loadPosts() async {
do {
let response = try await ButterCMS.shared.getPosts(page: currentPage)
posts = response.data
hasNextPage = response.meta.nextPage != nil
} catch {
print("Error: \(error)")
}
isLoading = false
}
private func loadMorePosts() async {
currentPage += 1
do {
let response = try await ButterCMS.shared.getPosts(page: currentPage)
posts.append(contentsOf: response.data)
hasNextPage = response.meta.nextPage != nil
} catch {
print("Error: \(error)")
}
}
}
// BlogPostView.swift
import SwiftUI
struct BlogPostView: View {
let slug: String
@State private var post: Post?
@State private var isLoading = true
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let post = post {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(post.title)
.font(.title)
.fontWeight(.bold)
HStack {
Text("By \(post.author.firstName) \(post.author.lastName)")
Spacer()
Text(formatDate(post.published))
}
.font(.caption)
.foregroundColor(.secondary)
if let imageURL = post.featuredImage,
let url = URL(string: imageURL) {
AsyncImage(url: url) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(8)
} placeholder: {
ProgressView()
}
}
HTMLTextView(html: post.body)
}
.padding()
}
}
}
.navigationBarTitleDisplayMode(.inline)
.task {
await loadPost()
}
}
private func loadPost() async {
do {
let response = try await ButterCMS.shared.getPost(slug: slug)
post = response.data
} catch {
print("Error: \(error)")
}
isLoading = false
}
private func formatDate(_ dateString: String) -> String {
let formatter = ISO8601DateFormatter()
guard let date = formatter.date(from: dateString) else { return dateString }
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
return displayFormatter.string(from: date)
}
}
Caching
UseURLCache for network caching:
// CacheConfig.swift
import Foundation
func configureURLCache() {
let cache = URLCache(
memoryCapacity: 10 * 1024 * 1024, // 10 MB memory
diskCapacity: 50 * 1024 * 1024, // 50 MB disk
diskPath: "buttercms_cache"
)
URLCache.shared = cache
}
Resources
Swift SDK
Swift SDK reference
Android Guide
Android integration
GitHub Repository
View source code
Content API
REST API documentation