JAMstack for Mobile Apps: Why Serve JSON Instead of Building APIs
I built festival apps serving thousands of concurrent users. No backend APIs. No servers melting down. Just pre-generated JSON files and 'islands' of dynamic content. Here's how JAMstack thinking changed mobile development for us.
Everyone knows JAMstack for web:
Generate static HTML → Deploy to CDN → Fast sites
But JAMstack for mobile apps?
That's what we built at Bondlayer. And it changed everything.
🎪 The Problem: Festival Apps That Don't Crash
Picture this:
Music festival. 90,000 attendees. Everyone opens the app at the same time.
Traditional approach:
User opens app
↓
App calls API: /api/schedule
↓
Server queries database
↓
Returns JSON
↓
App rendersWhat happens with 90K concurrent users?
Database connection pool: EXHAUSTED
Server CPU: MAXED OUT
API latency: 10+ seconds
User experience: "Why won't this app load?!"
We needed a different approach.
💡 The Insight: Pre-Render Everything
For websites, we were already using JAMstack:
// Build time
const html = ReactDOMServer.renderToString(<Page data={staticData} />);
fs.writeFileSync('index.html', html);
// Runtime (user visits)
// Just serve static HTML from CDN
// No database queries!
The thought: What if we did the same for mobile apps?
📱 JAMstack for Mobile: The Architecture
Traditional Mobile App (API-First)
┌─────────────────┐
│ Mobile App │
│ │
│ 1. Render UI │
│ 2. Call API │
│ 3. Wait... │
│ 4. Update UI │
└────────┬────────┘
│
│ HTTP Request
↓
┌─────────────────┐
│ API Server │
│ │
│ Query DB │
│ Transform │
│ Return JSON │
└────────┬────────┘
│
↓
┌─────────────────┐
│ Database │
└─────────────────┘Problems:
❌ Network latency
❌ Server load
❌ Database bottlenecks
❌ Offline = broken
Our Approach (JSON-First)
Build Time:
┌─────────────────┐
│ Generate JSON │
│ per screen │
│ │
│ schedule.json │
│ artists.json │
│ venues.json │
└────────┬────────┘
│
↓
┌─────────────────┐
│ Upload to │
│ CDN (S3) │
└─────────────────┘
Runtime (User opens app):
┌─────────────────┐
│ Mobile App │
│ │
│ 1. Download │
│ screen.json │
│ 2. Render │
│ instantly │
└────────┬────────┘
│
│ HTTPS (cached!)
↓
┌─────────────────┐
│ CDN │
│ (S3/CloudFront)│
│ │
│ Static JSON │
└─────────────────┘Benefits:
✅ Instant rendering (no API wait)
✅ Infinite scalability (CDN handles load)
✅ Offline-first (cache JSON locally)
✅ No backend to crash
🛠️ How It Works: The Build Process
Step 1: Generate Data-Rich JSON Files
Instead of HTML, we generate JSON with all data pre-loaded:
// Build time (when user publishes app)
const generateScreenJSON = async (screen, projectData) => {
// Fetch ALL data this screen needs
const concerts = await db.query(`
SELECT * FROM project_content
WHERE collection = 'concerts'
AND data->>'date' = '2024-06-15'
`);
const artists = await db.query(`
SELECT * FROM project_content
WHERE collection = 'artists'
AND id = ANY($1)
`, [concerts.map(c => c.data.artist_id)]);
// Create enriched JSON
const screenJSON = {
...screen, // Screen layout/design
_data: { // Pre-fetched data
concerts: concerts.map(c => c.data),
artists: artists.reduce((acc, a) => {
acc[a.id] = a.data;
return acc;
}, {})
},
_generated: new Date().toISOString()
};
// Upload to CDN
await uploadToS3(
`mobile/${projectId}/screens/schedule.json`,
screenJSON
);
};The key: Data is embedded in the JSON, not fetched later.
Step 2: Render on Device
Mobile app downloads JSON and renders immediately:
// Mobile app (React Native)
import { Canvas } from './Canvas'; // Our renderer
const Screen = ({ screenId }) => {
const [screenData, setScreenData] = useState(null);
useEffect(() => {
// Download JSON (or load from cache)
const loadScreen = async () => {
const cached = await getFromCache(screenId);
if (cached) {
setScreenData(cached); // Instant!
return;
}
// Download from CDN
const url = `https://cdn.bondlayer.com/mobile/${projectId}/screens/${screenId}.json`;
const response = await fetch(url);
const data = await response.json();
// Cache locally
await saveToCache(screenId, data);
setScreenData(data);
};
loadScreen();
}, [screenId]);
if (!screenData) return <Loading />;
// Render screen with embedded data
return <Canvas structure={screenData} data={screenData._data} />;
};Step 3: Prefetch Next Screens
While user views screen A, prefetch screen B:
// Smart prefetching
const prefetchLinkedScreens = async (currentScreen) => {
// Find all screens this screen links to
const linkedScreens = findLinks(currentScreen);
// Download in background
linkedScreens.forEach(async (screenId) => {
const url = `${CDN_URL}/screens/${screenId}.json`;
const data = await fetch(url).then(r => r.json());
await saveToCache(screenId, data);
});
};
// User is on Schedule screen
// → Prefetch Concert Detail screens
// → Navigation feels instant (already cached!)🎯 The "Islands" of Dynamic Content
Not everything can be static.
Some content needs to be live:
User favorites (personal data)
Live concert updates
Real-time notifications
User-generated content
Solution: Islands of dynamic content
const ConcertSchedule = ({ concerts }) => {
// Static: Concert list (pre-generated JSON)
const [concertData] = useState(concerts);
// Dynamic: User favorites (API call)
const [favorites, setFavorites] = useState([]);
useEffect(() => {
// Small API call for user-specific data
fetchUserFavorites().then(setFavorites);
}, []);
return (
<View>
{concertData.map(concert => (
<ConcertCard
key={concert.id}
concert={concert} // Static
isFavorite={favorites.includes(concert.id)} // Dynamic
/>
))}
</View>
);
};Result:
95% of content: Pre-generated, instant
5% of content: Dynamic, API calls
Backend handles 5% of load instead of 100%
📊 Real-World Performance
Festival App Metrics (90K Concurrent Users)
Traditional API Approach (Estimated):
90,000 users × 10 API calls each = 900,000 requests
Database connection pool: 100 connections
Time per query: 50ms average
Result:
- Database: DEAD
- API servers: MELTING
- Response time: 10+ seconds
- Infrastructure cost: €5,000/monthOur JAMstack Approach (Actual):
90,000 users × 0 API calls (on first load)
CDN bandwidth: Unlimited
JSON files: Pre-generated, cached
Result:
- Database: Not hit at all
- CDN: Handles everything
- Response time: <100ms (network latency only)
- Infrastructure cost: €500/month18x cost reduction. Infinite scalability.
Screen Load Times
Before (API-first):
User taps "Schedule"
→ 500ms: API call
→ 200ms: Database query
→ 100ms: Transform data
→ 200ms: Network latency
Total: ~1 second (feels slow)After (JSON-first):
User taps "Schedule"
→ 0ms: Check cache (already downloaded!)
→ 50ms: Render screen
Total: ~50ms (feels instant)🚀 How We Updated Content
The magic: OTA (Over-The-Air) updates
// User publishes update
publishUpdate() {
// 1. Generate new JSON files
await generateAllScreens();
// 2. Update manifest
const manifest = {
version: '2.1.5',
screens: {
'schedule': {
url: 'screens/schedule.json',
hash: 'a1b2c3d4' // Content hash
},
'artists': {
url: 'screens/artists.json',
hash: 'e5f6g7h8'
}
}
};
await uploadToS3('manifest.json', manifest);
// 3. Apps check manifest on launch
// 4. Download only changed screens
// 5. Entire app updated!
}App update flow:
// On app launch
const checkForUpdates = async () => {
const localVersion = await getLocalManifest();
const remoteManifest = await fetch(`${CDN_URL}/manifest.json`);
// Compare versions
if (remoteManifest.version > localVersion.version) {
// Find changed screens (by hash)
const changed = compareManifests(localVersion, remoteManifest);
// Download only changed screens
for (const screen of changed) {
const data = await fetch(screen.url);
await saveToCache(screen.id, data);
}
// Update local manifest
await saveLocalManifest(remoteManifest);
// Reload app with new content!
reloadApp();
}
};During festival:
Organizer updates concert time: 3:00 PM → 3:30 PM
Publish button → JSON regenerated → CDN updated
Users open app → Check manifest → Download update
Total time: <1 minute
No app store approval needed!
🎨 The Technical Stack
Build Pipeline
// Step 1: Query database
const data = await fetchProjectData(projectId);
// Step 2: Generate JSON per screen
const screens = projectDesign.screens;
for (const screen of screens) {
const screenJSON = await generateScreenJSON(screen, data);
await uploadToS3(`screens/${screen.id}.json`, screenJSON);
}
// Step 3: Generate manifest
const manifest = generateManifest(screens);
await uploadToS3('manifest.json', manifest);
// Done! App can now download and render.Mobile Rendering
// Canvas.js (renders JSON structure)
const Canvas = ({ structure, data }) => {
const renderElement = (element) => {
const Component = componentMap[element.type];
// Get data for this element
const elementData = resolveData(element.dataSource, data);
return (
<Component
key={element.id}
data={elementData}
style={element.styles}
>
{element.children?.map(renderElement)}
</Component>
);
};
return renderElement(structure);
};Caching Strategy
// Three-layer cache
class CacheManager {
async get(key) {
// 1. Memory cache (instant)
if (memoryCache.has(key)) return memoryCache.get(key);
// 2. Disk cache (fast)
const disk = await AsyncStorage.getItem(key);
if (disk) {
memoryCache.set(key, JSON.parse(disk));
return JSON.parse(disk);
}
// 3. CDN (network)
const remote = await fetch(`${CDN_URL}/${key}`);
const data = await remote.json();
// Cache for next time
memoryCache.set(key, data);
await AsyncStorage.setItem(key, JSON.stringify(data));
return data;
}
}🤔 When This Approach Works
Great for:
✅ Content-driven apps (events, catalogs, news)
✅ High-traffic apps (festivals, conferences)
✅ Mostly-static content with some dynamic parts
✅ Offline-first requirements
✅ Cost-sensitive projects
Not great for:
❌ Social networks (all user-generated content)
❌ Real-time chat/messaging
❌ Apps with constant user data changes
❌ Collaborative editing tools
📈 The Results
After 10 years:
✅ 100+ mobile apps built this way
✅ Festival apps: 90K concurrent users, zero crashes
✅ Average app: <50MB total JSON for all screens
✅ CDN costs: $10-50/month per app
✅ OTA updates: Deploy fixes in minutes, not weeks
The trade-off:
More complex build process
Larger initial download (all data embedded)
Not suitable for all app types
But for our use case (content-driven apps with high traffic):
This approach was perfect.
💡 Key Lessons
1. JAMstack Isn't Just For Web
The core principle applies anywhere:
Pre-generate what you can
Make it static
Serve from CDN
Add dynamic pieces where needed2. CDNs Are Magical
90,000 users hitting a database = disaster 90,000 users hitting a CDN = Tuesday
3. Offline-First as a Bonus
We built this for performance. Got offline-capability for free.
4. Islands Over Oceans
You don't need to make EVERYTHING static. Just make 95% static, 5% dynamic.
Islands of dynamic content in an ocean of static content.
5. Build Complexity vs. Runtime Simplicity
Yes, the build process is complex. But the runtime is simple (just download and render).
Complex build, simple app = scalable system.
The Conclusion
Everyone talks about JAMstack for web.
We applied it to mobile apps.
The result:
90,000 concurrent users
Zero backend crashes
Cheap infrastructure
Instant screen loads
Offline-first for free
Traditional approach:
APIs melting under load
Database connection pools exhausted
Slow, expensive, fragile
Sometimes the best architecture is:
Pre-render everything. Serve from CDN. Add dynamic islands where needed.
Something to add or contest? I'm always open to technical debate.
Start a discussion