Progressive Web Apps (PWA)
Building a PWA Project (Part 1)
Building a PWA Project (Part 1)
In this lesson, we'll build a complete Progressive Web App from scratch. We'll create a task management app called "TaskMaster PWA" that works offline, is installable, and provides a native-like experience.
Project Overview
Our TaskMaster PWA will include:
- Task creation, editing, and deletion
- Offline functionality with local storage
- App shell architecture for instant loading
- Service worker for caching and offline support
- Web app manifest for installability
- Responsive design for all devices
Project Setup
First, let's create the project structure:
taskmaster-pwa/
├── index.html
├── manifest.json
├── sw.js
├── css/
│ └── styles.css
├── js/
│ ├── app.js
│ └── db.js
├── images/
│ ├── icons/
│ │ ├── icon-72x72.png
│ │ ├── icon-96x96.png
│ │ ├── icon-128x128.png
│ │ ├── icon-144x144.png
│ │ ├── icon-152x152.png
│ │ ├── icon-192x192.png
│ │ ├── icon-384x384.png
│ │ └── icon-512x512.png
│ └── offline.svg
└── offline.html
Creating the Web App Manifest
The manifest.json file defines how your app appears and behaves when installed:
{
"name": "TaskMaster PWA",
"short_name": "TaskMaster",
"description": "A powerful offline-first task management app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"icons": [
{
"src": "/images/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/images/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "/images/screenshots/mobile-1.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/images/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "Add Task",
"short_name": "Add",
"description": "Create a new task",
"url": "/?action=add",
"icons": [
{
"src": "/images/icons/add-icon.png",
"sizes": "96x96"
}
]
},
{
"name": "View All Tasks",
"short_name": "All",
"description": "View all tasks",
"url": "/?view=all",
"icons": [
{
"src": "/images/icons/list-icon.png",
"sizes": "96x96"
}
]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
Icon Guidelines: Use maskable icons that work with adaptive icon shapes on Android. Ensure your icon has a safe zone in the center 40% of the image.
HTML Structure (index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="A powerful offline-first task management app">
<meta name="theme-color" content="#2196F3">
<!-- Web App Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/images/icons/icon-152x152.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="TaskMaster">
<!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="/images/icons/icon-72x72.png">
<title>TaskMaster PWA</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<!-- App Shell -->
<div class="app">
<!-- Header -->
<header class="app-header">
<h1>TaskMaster</h1>
<button id="installBtn" class="install-btn" style="display: none;">
Install App
</button>
</header>
<!-- Main Content -->
<main class="app-main">
<!-- Add Task Form -->
<section class="add-task-section">
<h2>Add New Task</h2>
<form id="taskForm">
<input
type="text"
id="taskTitle"
placeholder="Task title"
required
autocomplete="off"
>
<textarea
id="taskDescription"
placeholder="Task description (optional)"
rows="3"
></textarea>
<div class="form-row">
<select id="taskPriority">
<option value="low">Low Priority</option>
<option value="medium" selected>Medium Priority</option>
<option value="high">High Priority</option>
</select>
<input type="date" id="taskDueDate">
</div>
<button type="submit" class="btn btn-primary">Add Task</button>
</form>
</section>
<!-- Task Filters -->
<section class="filter-section">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="active">Active</button>
<button class="filter-btn" data-filter="completed">Completed</button>
</section>
<!-- Task List -->
<section class="task-list-section">
<h2>My Tasks</h2>
<div id="taskList" class="task-list">
<!-- Tasks will be dynamically inserted here -->
</div>
<div id="emptyState" class="empty-state">
<p>No tasks yet. Add your first task above!</p>
</div>
</section>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="connection-status">
<span id="onlineStatus" class="status-indicator online"></span>
<span id="statusText">Online</span>
</div>
</footer>
</div>
<!-- Loading Skeleton (App Shell) -->
<div id="appShell" class="app-shell" style="display: none;">
<div class="skeleton-header"></div>
<div class="skeleton-content">
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
</div>
</div>
<script src="/js/db.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
Service Worker Registration
In js/app.js, register the service worker:
// Service Worker Registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
showUpdateNotification();
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
function showUpdateNotification() {
if (confirm('New version available! Reload to update?')) {
window.location.reload();
}
}
App Shell Implementation
The app shell is the minimal HTML, CSS, and JavaScript needed to power the user interface:
// App Shell CSS (styles.css)
:root {
--primary-color: #2196F3;
--secondary-color: #FFC107;
--success-color: #4CAF50;
--danger-color: #F44336;
--text-color: #333;
--bg-color: #f5f5f5;
--white: #ffffff;
--shadow: 0 2px 8px rgba(0,0,0,0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
.app {
max-width: 800px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--white);
box-shadow: var(--shadow);
}
/* Header Styles */
.app-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #1976D2 100%);
color: var(--white);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 600;
}
/* Loading Skeleton */
.app-shell {
max-width: 800px;
margin: 0 auto;
background-color: var(--white);
}
.skeleton-header {
height: 60px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.skeleton-content {
padding: 1rem;
}
.skeleton-item {
height: 80px;
margin-bottom: 1rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 8px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Basic Service Worker with Caching
// sw.js
const CACHE_NAME = 'taskmaster-v1';
const STATIC_CACHE = 'taskmaster-static-v1';
const DYNAMIC_CACHE = 'taskmaster-dynamic-v1';
// Assets to cache on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/css/styles.css',
'/js/app.js',
'/js/db.js',
'/manifest.json',
'/images/offline.svg'
];
// Install event - cache static assets
self.addEventListener('install', event => {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => {
console.log('Caching app shell');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
console.log('Service Worker activating...');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map(name => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then(networkResponse => {
// Cache dynamic content
if (event.request.method === 'GET') {
return caches.open(DYNAMIC_CACHE)
.then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
}
return networkResponse;
})
.catch(() => {
// Offline fallback
if (event.request.destination === 'document') {
return caches.match('/offline.html');
}
});
})
);
});
Creating the Offline Page
<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - TaskMaster PWA</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem;
}
.offline-container {
max-width: 500px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1.2rem;
margin-bottom: 2rem;
}
.retry-btn {
background-color: white;
color: #667eea;
border: none;
padding: 1rem 2rem;
font-size: 1rem;
border-radius: 50px;
cursor: pointer;
transition: transform 0.2s;
}
.retry-btn:hover {
transform: scale(1.05);
}
</style>
</head>
<body>
<div class="offline-container">
<h1>You're Offline</h1>
<p>Don't worry! Your tasks are saved locally and will sync when you're back online.</p>
<button class="retry-btn" onclick="window.location.reload()">
Try Again
</button>
</div>
</body>
</html>
Exercise: Set up the TaskMaster PWA project structure, create the manifest.json file, and implement the basic service worker with static asset caching. Test that the offline page displays when you disable your network connection.
Next Steps: In Part 2, we'll add IndexedDB for data persistence, implement push notifications, and add the install prompt functionality.