Building a Content API for Mobile Apps: Complete Guide

Learn how to build a content API for iOS, Android, and React Native apps. Step-by-step guide with code examples for connecting your mobile app to a headless CMS.

R
Raşit Apalak
11 min read

From CMS to mobile app in under an hour

Your mobile app needs content. Blog posts, product listings, announcements, in-app messages. You could hardcode it, but then every update requires a new app submission and a 2-day review process.

The better approach: a content API that your app fetches from. Update content in your CMS, and your app reflects changes instantly. No app store submission needed.

This guide shows you how to build this system from scratch.


Table of Contents


Why Use a Content API for Mobile Apps

The Problem with Hardcoded Content

Every time you want to change text, images, or any content in your app:

  1. Developer opens the codebase
  2. Makes the change
  3. Commits, builds, tests
  4. Submits to App Store / Play Store
  5. Waits 1-2 days for review
  6. Users update the app

For a simple typo fix or promotional banner change, this is absurd.

The Content API Approach

  1. Content editor logs into CMS
  2. Makes the change
  3. Hits "Publish"
  4. App fetches new content within seconds/minutes

Result: Content changes are instant. No developer involvement. No app store review.

What You Can Manage via Content API

  • Blog posts and articles - Keep your in-app blog always fresh
  • Product catalogs - Update pricing, descriptions, availability
  • Promotional banners - Seasonal sales, announcements
  • FAQ and help content - Answer common questions without code changes
  • App configuration - Feature flags, settings, translations
  • Push notification content - Craft messages in a proper editor
  • Onboarding flows - A/B test different welcome screens

Choosing Your Backend: CMS Options

For mobile app content, you need a headless CMS with:

  1. REST API - Standard HTTP that any platform can consume
  2. Fast response times - Mobile users don't wait
  3. CDN-friendly - Cacheable responses for global performance
  4. Simple pricing - Predictable costs as your app grows

Comparison for Mobile Use Cases

CMSAPI TypeLatencyPricingMobile SDK
ElmapiCMSRESTFast$149 one-timeJS SDK
StrapiREST/GraphQLMediumFree/CloudNone
ContentfulREST/GraphQLFastUsage-basediOS, Android, JS
SanityGROQFastUsage-basedJS

For most mobile projects, ElmapiCMS offers the best value. One-time cost, simple REST API, and you can host it on your own infrastructure for consistent latency.


Setting Up Your Content API

Let's set up ElmapiCMS as your mobile app's content backend.

Step 1: Install ElmapiCMS

# Quick setup with Docker
cd ~/projects/elmapicms
composer install
cp .env.example .env
php artisan key:generate
php artisan sail:install
./vendor/bin/sail up -d
./vendor/bin/sail artisan migrate --seed

Step 2: Create a Mobile App Project

  1. Log in at http://localhost:8000
  2. Click "Create Project"
  3. Name it "Mobile App Content"

Step 3: Design Your Content Structure

For a typical mobile app, create these collections:

Blog Posts:

  • title (Text)
  • slug (Text, unique)
  • excerpt (Text)
  • content (Rich Text)
  • featuredImage (Media)
  • publishedAt (Date)
  • author (Text)

Announcements:

  • title (Text)
  • message (Text)
  • type (Select: info, warning, promo)
  • actionUrl (Text)
  • startDate (Date)
  • endDate (Date)
  • isActive (Boolean)

App Config:

  • key (Text, unique)
  • value (Text)
  • description (Text)

Step 4: Generate API Key

  1. Go to your project's API settings
  2. Create a new API key
  3. Set permissions to read-only (for mobile apps)
  4. Copy the key for your app

Step 5: Test Your API

curl -X GET "https://your-cms.com/api/entries/blog-posts" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "X-Project-ID: your-project-id"

Response:

{
  "data": [
    {
      "uuid": "abc-123",
      "fields": {
        "title": "Welcome to Our App",
        "slug": "welcome",
        "excerpt": "Getting started with...",
        "content": "<p>Full content here...</p>",
        "featuredImage": "https://your-cms.com/storage/images/welcome.jpg"
      }
    }
  ],
  "meta": {
    "total": 1,
    "page": 1
  }
}

Connecting from React Native / Expo

Here's how to fetch content in a React Native or Expo app:

Install the SDK

npm install @elmapicms/js-sdk

Create an API Client

// src/lib/cms.ts
import { createClient } from '@elmapicms/js-sdk';

export const cmsClient = createClient(
  process.env.EXPO_PUBLIC_CMS_URL!,
  process.env.EXPO_PUBLIC_CMS_API_KEY!,
  process.env.EXPO_PUBLIC_CMS_PROJECT_ID!
);

Fetch Blog Posts

// src/hooks/useBlogPosts.ts
import { useState, useEffect } from 'react';
import { cmsClient } from '../lib/cms';

interface BlogPost {
  uuid: string;
  fields: {
    title: string;
    slug: string;
    excerpt: string;
    content: string;
    featuredImage: string;
    publishedAt: string;
  };
}

export function useBlogPosts() {
  const [posts, setPosts] = useState<BlogPost[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        const response = await cmsClient.getEntries('blog-posts', {
          sort: '-publishedAt',
          paginate: 20,
        });
        setPosts(response.data);
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    }

    fetchPosts();
  }, []);

  return { posts, loading, error };
}

Display in Your Component

// src/screens/BlogScreen.tsx
import { View, FlatList, Text, Image, Pressable } from 'react-native';
import { useBlogPosts } from '../hooks/useBlogPosts';

export function BlogScreen({ navigation }) {
  const { posts, loading, error } = useBlogPosts();

  if (loading) {
    return <ActivityIndicator />;
  }

  if (error) {
    return <Text>Failed to load posts</Text>;
  }

  return (
    <FlatList
      data={posts}
      keyExtractor={(item) => item.uuid}
      renderItem={({ item }) => (
        <Pressable
          onPress={() => navigation.navigate('BlogPost', { slug: item.fields.slug })}
          style={styles.card}
        >
          <Image
            source={{ uri: item.fields.featuredImage }}
            style={styles.image}
          />
          <View style={styles.content}>
            <Text style={styles.title}>{item.fields.title}</Text>
            <Text style={styles.excerpt}>{item.fields.excerpt}</Text>
          </View>
        </Pressable>
      )}
    />
  );
}

Connecting from iOS (Swift)

For native iOS apps:

Create the API Client

// CMSClient.swift
import Foundation

class CMSClient {
    private let baseURL: String
    private let apiKey: String
    private let projectId: String
    
    init(baseURL: String, apiKey: String, projectId: String) {
        self.baseURL = baseURL
        self.apiKey = apiKey
        self.projectId = projectId
    }
    
    func getEntries<T: Decodable>(
        collection: String,
        completion: @escaping (Result<[T], Error>) -> Void
    ) {
        guard let url = URL(string: "\(baseURL)/api/entries/\(collection)") else {
            return
        }
        
        var request = URLRequest(url: url)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.setValue(projectId, forHTTPHeaderField: "X-Project-ID")
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let data = data else {
                completion(.failure(NSError(domain: "", code: -1)))
                return
            }
            
            do {
                let response = try JSONDecoder().decode(
                    APIResponse<T>.self, 
                    from: data
                )
                completion(.success(response.data))
            } catch {
                completion(.failure(error))
            }
        }.resume()
    }
}

struct APIResponse<T: Decodable>: Decodable {
    let data: [T]
}

Define Your Models

// BlogPost.swift
struct BlogPost: Decodable, Identifiable {
    let uuid: String
    let fields: BlogPostFields
    
    var id: String { uuid }
}

struct BlogPostFields: Decodable {
    let title: String
    let slug: String
    let excerpt: String
    let content: String
    let featuredImage: String
    let publishedAt: String
}

Fetch and Display

// BlogViewModel.swift
import SwiftUI

class BlogViewModel: ObservableObject {
    @Published var posts: [BlogPost] = []
    @Published var isLoading = true
    
    private let client = CMSClient(
        baseURL: "https://your-cms.com",
        apiKey: "your-api-key",
        projectId: "your-project-id"
    )
    
    func fetchPosts() {
        client.getEntries(collection: "blog-posts") { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let posts):
                    self?.posts = posts
                case .failure(let error):
                    print("Error: \(error)")
                }
                self?.isLoading = false
            }
        }
    }
}

Connecting from Android (Kotlin)

For native Android apps using Retrofit:

Add Dependencies

// build.gradle
dependencies {
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation 'com.squareup.okhttp3:okhttp:4.11.0'
}

Create the API Interface

// CMSApi.kt
interface CMSApi {
    @GET("api/entries/{collection}")
    suspend fun getEntries(
        @Path("collection") collection: String,
        @Header("Authorization") auth: String,
        @Header("X-Project-ID") projectId: String
    ): Response<EntriesResponse<BlogPost>>
}

data class EntriesResponse<T>(
    val data: List<T>
)

data class BlogPost(
    val uuid: String,
    val fields: BlogPostFields
)

data class BlogPostFields(
    val title: String,
    val slug: String,
    val excerpt: String,
    val content: String,
    val featuredImage: String,
    val publishedAt: String
)

Create the Repository

// CMSRepository.kt
class CMSRepository {
    private val api: CMSApi = Retrofit.Builder()
        .baseUrl("https://your-cms.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(CMSApi::class.java)
    
    private val apiKey = "Bearer your-api-key"
    private val projectId = "your-project-id"
    
    suspend fun getBlogPosts(): List<BlogPost> {
        val response = api.getEntries("blog-posts", apiKey, projectId)
        return response.body()?.data ?: emptyList()
    }
}

Use in ViewModel

// BlogViewModel.kt
class BlogViewModel : ViewModel() {
    private val repository = CMSRepository()
    
    private val _posts = MutableStateFlow<List<BlogPost>>(emptyList())
    val posts: StateFlow<List<BlogPost>> = _posts
    
    init {
        fetchPosts()
    }
    
    private fun fetchPosts() {
        viewModelScope.launch {
            try {
                _posts.value = repository.getBlogPosts()
            } catch (e: Exception) {
                // Handle error
            }
        }
    }
}

Handling Offline Mode

Mobile apps need to work offline. Here's how to cache content locally:

React Native with AsyncStorage

// src/lib/cache.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

const CACHE_DURATION = 1000 * 60 * 60; // 1 hour

export async function getCachedOrFetch<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  try {
    // Try cache first
    const cached = await AsyncStorage.getItem(key);
    if (cached) {
      const { data, timestamp } = JSON.parse(cached);
      if (Date.now() - timestamp < CACHE_DURATION) {
        return data;
      }
    }
    
    // Fetch fresh data
    const fresh = await fetcher();
    await AsyncStorage.setItem(key, JSON.stringify({
      data: fresh,
      timestamp: Date.now()
    }));
    return fresh;
  } catch (error) {
    // If fetch fails, return stale cache if available
    const cached = await AsyncStorage.getItem(key);
    if (cached) {
      return JSON.parse(cached).data;
    }
    throw error;
  }
}

// Usage
const posts = await getCachedOrFetch('blog-posts', () => 
  cmsClient.getEntries('blog-posts')
);

iOS with UserDefaults

class CacheManager {
    private let defaults = UserDefaults.standard
    private let cacheDuration: TimeInterval = 3600 // 1 hour
    
    func getCachedOrFetch<T: Codable>(
        key: String,
        fetcher: @escaping (@escaping (Result<T, Error>) -> Void) -> Void,
        completion: @escaping (Result<T, Error>) -> Void
    ) {
        // Check cache
        if let cached = defaults.data(forKey: key),
           let timestamp = defaults.object(forKey: "\(key)_timestamp") as? Date,
           Date().timeIntervalSince(timestamp) < cacheDuration {
            do {
                let data = try JSONDecoder().decode(T.self, from: cached)
                completion(.success(data))
                return
            } catch {}
        }
        
        // Fetch fresh
        fetcher { result in
            if case .success(let data) = result {
                if let encoded = try? JSONEncoder().encode(data) {
                    self.defaults.set(encoded, forKey: key)
                    self.defaults.set(Date(), forKey: "\(key)_timestamp")
                }
            }
            completion(result)
        }
    }
}

Caching Strategies

1. Stale-While-Revalidate

Show cached content immediately, then update in background:

export function useBlogPostsSWR() {
  const [posts, setPosts] = useState<BlogPost[]>([]);
  
  useEffect(() => {
    // Show cached immediately
    getCached('blog-posts').then(cached => {
      if (cached) setPosts(cached);
    });
    
    // Fetch fresh in background
    fetchFresh('blog-posts').then(fresh => {
      setPosts(fresh);
      setCache('blog-posts', fresh);
    });
  }, []);
  
  return posts;
}

2. Cache-First with TTL

Only fetch if cache is expired:

const TTL = 1000 * 60 * 30; // 30 minutes

async function getWithTTL(key: string, fetcher: () => Promise<any>) {
  const cached = await getCache(key);
  if (cached && Date.now() - cached.timestamp < TTL) {
    return cached.data;
  }
  const fresh = await fetcher();
  await setCache(key, { data: fresh, timestamp: Date.now() });
  return fresh;
}

3. Network-First with Fallback

Try network first, fall back to cache on failure:

async function networkFirst(key: string, fetcher: () => Promise<any>) {
  try {
    const fresh = await fetcher();
    await setCache(key, fresh);
    return fresh;
  } catch {
    const cached = await getCache(key);
    if (cached) return cached;
    throw new Error('No cached data available');
  }
}

Performance Optimization

1. Paginate Large Collections

Don't fetch all content at once:

const posts = await cmsClient.getEntries('blog-posts', {
  paginate: 20,
  page: 1,
});

2. Select Only Needed Fields

If your CMS supports field selection:

const posts = await cmsClient.getEntries('blog-posts', {
  fields: ['title', 'excerpt', 'featuredImage'],
});

3. Compress Images

Ensure your CMS serves optimized images. ElmapiCMS integrates with storage services that can resize images on the fly.

4. Use a CDN

Put your CMS behind a CDN for global performance:

  • Cloudflare
  • Fastly
  • AWS CloudFront

5. Implement Pull-to-Refresh

Let users manually refresh when they want fresh content:

<FlatList
  refreshControl={
    <RefreshControl
      refreshing={refreshing}
      onRefresh={handleRefresh}
    />
  }
  // ...
/>

Conclusion

A content API transforms how you manage mobile app content. No more app store delays for simple updates. No more developer involvement for content changes. Just update your CMS and watch it appear in your app.

Key takeaways:

  1. Use a headless CMS for flexible, API-first content management
  2. Cache aggressively for offline support and performance
  3. Paginate responses to keep API calls fast
  4. Implement refresh mechanisms so users can get fresh content when needed

ElmapiCMS provides everything you need: simple REST API, fast responses, and one-time pricing that doesn't scale with your user base.

Ready to build? Try the demo or get ElmapiCMS to start building your mobile content API.


Related Posts:

Share this post:

Related posts