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
- Choosing Your Backend: CMS Options
- Setting Up Your Content API
- Connecting from React Native / Expo
- Connecting from iOS (Swift)
- Connecting from Android (Kotlin)
- Handling Offline Mode
- Caching Strategies
- Performance Optimization
- Conclusion
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:
- Developer opens the codebase
- Makes the change
- Commits, builds, tests
- Submits to App Store / Play Store
- Waits 1-2 days for review
- Users update the app
For a simple typo fix or promotional banner change, this is absurd.
The Content API Approach
- Content editor logs into CMS
- Makes the change
- Hits "Publish"
- 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:
- REST API - Standard HTTP that any platform can consume
- Fast response times - Mobile users don't wait
- CDN-friendly - Cacheable responses for global performance
- Simple pricing - Predictable costs as your app grows
Comparison for Mobile Use Cases
| CMS | API Type | Latency | Pricing | Mobile SDK |
|---|---|---|---|---|
| ElmapiCMS | REST | Fast | $149 one-time | JS SDK |
| Strapi | REST/GraphQL | Medium | Free/Cloud | None |
| Contentful | REST/GraphQL | Fast | Usage-based | iOS, Android, JS |
| Sanity | GROQ | Fast | Usage-based | JS |
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
- Log in at
http://localhost:8000 - Click "Create Project"
- 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
- Go to your project's API settings
- Create a new API key
- Set permissions to read-only (for mobile apps)
- 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:
- Use a headless CMS for flexible, API-first content management
- Cache aggressively for offline support and performance
- Paginate responses to keep API calls fast
- 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: