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 APIFirst 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
- Minimal Shell: Keep shell under 50KB
- Inline Critical CSS: Avoid render-blocking
- Cache Aggressively: Shell should load instantly
- Skeleton Screens: Show structure while loading
- Progressive Enhancement: Work without JS
- Offline First: App shell always works
- Fast API: Optimize data fetching
- Preload Resources: Critical assets
- Monitor Performance: Track metrics
- 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!