Back to Notes
mobile

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 renders

What 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/month

Our 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/month

18x 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 needed

2. 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
JAMstack for Mobile Apps: Why Serve JSON Instead of Building APIs | Sérgio Oliveira