Progressive Web Apps (PWA)

Building a PWA Project (Part 1)

20 min Lesson 26 of 30

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.