Front-end Engineering Lab
PatternsMobile & PWA

App Shell Architecture

Load the UI shell instantly for perceived performance

App Shell Architecture loads the minimal HTML, CSS, and JavaScript needed to power the user interface instantly, then loads content progressively. Used by Google, Twitter, and major PWAs.

The Concept

App Shell (cached):
├── Header
├── Navigation
├── Footer
└── Loading skeleton

Content (dynamic):
└── Fetched from API

First Load: Cache app shell
Return Visits: Instant shell → Load content

Basic Structure

HTML Shell

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My App</title>
  
  <!-- Inline critical CSS -->
  <style>
    /* Minimal styles for app shell */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 60px; background: #000; color: #fff; }
    .nav { width: 250px; background: #f5f5f5; }
    .content { flex: 1; padding: 20px; }
    .skeleton { background: #e0e0e0; animation: pulse 1.5s infinite; }
  </style>
</head>
<body>
  <!-- App Shell -->
  <header class="header">
    <h1>My App</h1>
  </header>
  
  <div class="layout">
    <nav class="nav">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/profile">Profile</a></li>
        <li><a href="/settings">Settings</a></li>
      </ul>
    </nav>
    
    <main class="content">
      <!-- Content loaded here -->
      <div id="app"></div>
    </main>
  </div>
  
  <script src="/app.js"></script>
</body>
</html>

Service Worker Caching

// sw.js
const SHELL_CACHE = 'app-shell-v1';
const SHELL_FILES = [
  '/',
  '/index.html',
  '/app.js',
  '/app.css',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

// Install: Cache app shell
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then((cache) => {
      return cache.addAll(SHELL_FILES);
    })
  );
  self.skipWaiting();
});

// Activate: Clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== SHELL_CACHE)
          .map((name) => caches.delete(name))
      );
    })
  );
  self.clients.claim();
});

// Fetch: Serve app shell from cache
self.addEventListener('fetch', (event) => {
  const { request } = event;
  
  // For navigation requests, always serve app shell
  if (request.mode === 'navigate') {
    event.respondWith(
      caches.match('/').then((response) => {
        return response || fetch(request);
      })
    );
    return;
  }
  
  // For other requests, try cache first
  event.respondWith(
    caches.match(request).then((response) => {
      return response || fetch(request);
    })
  );
});

Next.js Implementation

Layout (Shell)

// app/layout.tsx
import { Header } from '@/components/Header';
import { Navigation } from '@/components/Navigation';
import '@/styles/app-shell.css';

export default function RootLayout({ children }: Props) {
  return (
    <html lang="en">
      <head>
        <link rel="manifest" href="/manifest.json" />
        <meta name="theme-color" content="#000000" />
      </head>
      <body>
        {/* App Shell - Always visible */}
        <Header />
        
        <div className="layout">
          <Navigation />
          
          <main className="content">
            {/* Dynamic content */}
            {children}
          </main>
        </div>
        
        <script src="/register-sw.js" />
      </body>
    </html>
  );
}

Loading States

// app/loading.tsx
export default function Loading() {
  return (
    <div className="skeleton-container">
      <div className="skeleton skeleton-title" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-image" />
    </div>
  );
}
/* styles/skeleton.css */
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.skeleton-title {
  height: 32px;
  width: 60%;
  margin-bottom: 16px;
}

.skeleton-text {
  height: 16px;
  width: 100%;
  margin-bottom: 8px;
}

.skeleton-image {
  height: 200px;
  width: 100%;
  margin-top: 16px;
}

Progressive Enhancement

Content Loading Strategy

// utils/content-loader.ts
export async function loadContent(route: string) {
  // Show skeleton immediately
  showSkeleton();
  
  try {
    // Try cache first (stale-while-revalidate)
    const cached = await getCachedContent(route);
    if (cached) {
      renderContent(cached);
    }
    
    // Fetch fresh data
    const fresh = await fetchContent(route);
    
    // Update if different
    if (JSON.stringify(fresh) !== JSON.stringify(cached)) {
      renderContent(fresh);
      cacheContent(route, fresh);
    }
  } catch (error) {
    // Show error state
    showError();
  } finally {
    hideSkeleton();
  }
}

async function getCachedContent(route: string) {
  const cache = await caches.open('content-cache');
  const response = await cache.match(route);
  return response?.json();
}

async function fetchContent(route: string) {
  const response = await fetch(`/api${route}`);
  return response.json();
}

async function cacheContent(route: string, data: any) {
  const cache = await caches.open('content-cache');
  const response = new Response(JSON.stringify(data));
  await cache.put(route, response);
}

React Implementation

Shell Component

// components/AppShell.tsx
import { useState, useEffect } from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { Footer } from './Footer';

export function AppShell({ children }: Props) {
  const [shellReady, setShellReady] = useState(false);

  useEffect(() => {
    // Mark shell as ready for progressive enhancement
    setShellReady(true);
    
    // Preload critical resources
    preloadResources([
      '/api/user',
      '/icons/logo.svg',
    ]);
  }, []);

  return (
    <div className="app-shell" data-shell-ready={shellReady}>
      <Header />
      
      <div className="app-body">
        <Sidebar />
        
        <main className="app-content">
          {children}
        </main>
      </div>
      
      <Footer />
    </div>
  );
}

function preloadResources(urls: string[]) {
  urls.forEach((url) => {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = url;
    document.head.appendChild(link);
  });
}

Skeleton Screens

// components/ContentSkeleton.tsx
export function ContentSkeleton({ type }: { type: 'list' | 'detail' | 'grid' }) {
  if (type === 'list') {
    return (
      <div className="skeleton-list">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="skeleton-list-item">
            <div className="skeleton skeleton-avatar" />
            <div className="skeleton-list-content">
              <div className="skeleton skeleton-title" />
              <div className="skeleton skeleton-text" />
            </div>
          </div>
        ))}
      </div>
    );
  }

  if (type === 'grid') {
    return (
      <div className="skeleton-grid">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="skeleton-card">
            <div className="skeleton skeleton-image" />
            <div className="skeleton skeleton-title" />
            <div className="skeleton skeleton-text" />
          </div>
        ))}
      </div>
    );
  }

  return (
    <div className="skeleton-detail">
      <div className="skeleton skeleton-hero" />
      <div className="skeleton skeleton-title" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-text" />
    </div>
  );
}

Critical CSS Inlining

// scripts/inline-critical-css.ts
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';

// Extract critical CSS for app shell
const criticalCSS = `
  body { margin: 0; font-family: -apple-system, sans-serif; }
  .header { height: 60px; background: #000; color: #fff; }
  .nav { width: 250px; }
  .content { flex: 1; }
  .skeleton { background: #e0e0e0; animation: pulse 1.5s infinite; }
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
`;

// Inline into HTML
const html = readFileSync(resolve('public/index.html'), 'utf-8');
const updatedHTML = html.replace(
  '</head>',
  `<style>${criticalCSS}</style></head>`
);

writeFileSync(resolve('public/index.html'), updatedHTML);

Offline Support

// components/OfflineIndicator.tsx
import { useState, useEffect } from 'react';

export function OfflineIndicator() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  if (isOnline) return null;

  return (
    <div className="offline-banner">
      <span>📡 You're offline</span>
      <span>Some features may be limited</span>
    </div>
  );
}

Performance Metrics

// Measure app shell performance
if ('PerformanceObserver' in window) {
  const observer = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (entry.name === 'first-contentful-paint') {
        console.log('FCP (App Shell):', entry.startTime);
      }
    });
  });
  
  observer.observe({ entryTypes: ['paint'] });
}

// Measure content loading
const contentStart = performance.now();
loadContent('/api/posts').then(() => {
  const contentTime = performance.now() - contentStart;
  console.log('Content loaded in:', contentTime, 'ms');
});

Best Practices

  1. Minimal Shell: Keep shell under 50KB
  2. Inline Critical CSS: Avoid render-blocking
  3. Cache Aggressively: Shell should load instantly
  4. Skeleton Screens: Show structure while loading
  5. Progressive Enhancement: Work without JS
  6. Offline First: App shell always works
  7. Fast API: Optimize data fetching
  8. Preload Resources: Critical assets
  9. Monitor Performance: Track metrics
  10. Test Offline: Verify shell caching

Common Pitfalls

Heavy Shell: Too much in initial load
Minimal shell, lazy load rest

No Skeleton: Blank screen while loading
Show structure immediately

Blocking CSS: Delays shell render
Inline critical CSS

No Offline: Fails without network
Cache shell, work offline

Slow API: Content takes forever
Optimize API, use caching

App Shell Architecture creates instant-feeling apps—cache the shell, load content progressively!

On this page