Skip to content
Breaking
Latest technical intelligence from Northeast India • Infrastructure, AI, Cloud & Security Analysis • Precision Analysis | Raw Intelligence | Your North Star of Tech Latest technical intelligence from Northeast India • Infrastructure, AI, Cloud & Security Analysis • Precision Analysis | Raw Intelligence | Your North Star of Tech
WEBDEV

Analysis: Build a Next.js 16 PWA with true offline support

Building Offline-Capable Progressive Web Apps with Next.js

Building Offline-Capable Progressive Web Apps with Next.js

In today's connected world, the importance of Progressive Web Apps (PWAs) cannot be overstated. PWAs, which offer a seamless user experience across various devices and networks, are becoming increasingly popular for their ability to bridge the gap between traditional web and native mobile apps. However, ensuring that PWAs function optimally in low-connectivity situations is crucial, especially for users in the North East region of India where network coverage may be inconsistent.

The Importance of True Offline Support

When we talk about PWAs working offline, we often refer to different things. This distinction matters because it shapes how we build the app and what users can actually do when there is no internet connection. The app shell model is the most basic form of offline support. It focuses on saving the parts of your app that rarely change, such as the layout, navigation, styles, fonts, and JavaScript bundles. However, this approach solves only the loading problem, not the functionality issue. True offline support goes a step further, allowing data and user actions to be handled without an internet connection.

Beyond the Basic App Shell Approach

Building a Next.js PWA that works well when the network is slow, flaky, or offline altogether requires more than just setting up a PWA plugin, adding a manifest, and caching static assets. To achieve true offline support, we need to decide what to cache, store data locally, sync changes when connectivity returns, and design an experience that feels dependable rather than fragile.

Building a Next.js 16 PWA with True Offline Support

To illustrate what true offline support looks like in practice, we will build a simple todo app as a Progressive Web App using Next.js 16. The app will let users manage their tasks even when they are offline. They will be able to add new todos, mark them as complete, and delete them without an internet connection. Once the device is back online, the app will automatically sync those changes and bring the task list up to date.

Setting Up the Project

  • Create a new Next.js project:
  • npx create-next-app@latest nextjs-pwa-offline
  • Change into the project directory:
  • cd nextjs-pwa-offline
  • Install the PWA-related packages:
  • npm install @serwist/next @serwist/precaching @serwist/sw idbNext
  • Configure Next.js to enable PWA support:
  • touch next.config.ts
    nano next.config.ts
    // Add the following content:
    import type { NextConfig } from "next"; import withSerwistInit from "@serwist/next"; const withSerwist = withSerwistInit({ swSrc: "app/sw.ts", swDest: "public/sw.js", cacheOnNavigation: true, reloadOnOnline: true, disable: process.env.NODE_ENV === "development", }); const nextConfig: NextConfig = { reactStrictMode: true, }; export default withSerwist(nextConfig);

Creating the Service Worker

mkdir app touch app/sw.ts
nano app/sw.ts
// Add the following content:
import { defaultCache } from "@serwist/next/worker"; import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; import { Serwist } from "serwist"; declare global { interface WorkerGlobalScope extends SerwistGlobalConfig { __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; } } declare const self: WorkerGlobalScope; const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, skipWaiting: true, clientsClaim: true, navigationPreload: true, runtimeCaching: defaultCache, }); serwist.addEventListeners();

Setting Up the PWA Manifest

touch app/manifest.ts
nano app/manifest.ts
// Add the following content:
import { MetadataRoute } from 'next' export default function manifest(): MetadataRoute.Manifest { return { name: 'Offline Todo App', short_name: 'Todos', description: 'A todo app that works offline', start_url: '/', display: 'standalone', background_color: '#ffffff', theme_color: '#3b82f6', icons: [ { src: '/icons/icon-192x192.svg', sizes: '192x192', type: 'image/svg+xml', }, { src: '/icons/icon-512x512.svg', sizes: '512x512', type: 'image/svg+xml', }, ], } }

Setting Up IndexedDB

mkdir lib touch lib/db.ts
nano lib/db.ts
// Add the following content:
import { openDB, IDBPDatabase } from 'idb' export interface Todo { id: string text: string completed: boolean createdAt: number synced: boolean } let dbInstance: IDBPDatabase | null = null export async function getDB() { if (dbInstance) return dbInstance dbInstance = await openDB('todo-db', 1, { upgrade(db) { const todoStore = db.createObjectStore('todos', { keyPath: 'id' }) todoStore.createIndex('by-synced', 'synced') }, }) return dbInstance } export async function addTodo(text: string): Promise { const db = await getDB() const todo: Todo = { id: crypto.randomUUID(), text, completed: false, createdAt: Date.now(), synced: false, } await db.add('todos', todo) return todo } export async function getTodos(): Promise { const db = await getDB() return db.getAll('todos') } export async function updateTodo(id: string, updates: Partial): Promise { const db = await getDB() const todo = await db.get('todos', id) if (!todo) return const updated = { ...todo, ...updates, synced: false } await db.put('todos', updated) } export async function deleteTodo(id: string): Promise { const db = await getDB() await db.delete('todos', id) } export async function getUnsyncedTodos(): Promise { const db = await getDB() return db.getAll('todos').filter((todo: Todo) => !todo.synced) } export async function markAsSynced(id: string): Promise { const db = await getDB() const todo = await db.get('todos', id) if (!todo) return todo.synced = true await db.put('todos', todo) }

Setting Up Sync Logic

touch lib/sync.ts
nano lib/sync.ts
// Add the following content:
import { getUnsyncedTodos, markAsSynced } from './db' export async function syncTodos() { if (!navigator.onLine) { console.log('Offline - will sync later') return } const unsyncedTodos = await getUnsyncedTodos() if (unsyncedTodos.length === 0) { return } console.log(`Syncing ${unsyncedTodos.length} todos...`) for (const todo of unsyncedTodos) { try { // in a real app, send to your API: // await fetch('/api/todos', { // method: 'POST', // body: JSON.stringify(todo) // }) await markAsSynced(todo.id) } catch (error) { console.error('Sync failed:', error) } } } // auto-sync when coming back online if (typeof window !== 'undefined') { window.addEventListener('online', () => { console.log('Back online! Syncing...') syncTodos() }) }

Building the Todo Component

mkdir components touch components/TodoList.tsx
nano components/TodoList.tsx
// Add the following content:
// ... (omitted for brevity)

Adding an Offline Indicator

touch components/OnlineStatus.tsx
nano components/OnlineStatus.tsx
// Add the following content:
// ... (omitted for brevity)

Putting Everything Together

touch app/layout.tsx
touch app/page.tsx
// ... (omitted for brevity)

Conclusion

Building a truly offline-capable PWA is more approachable than it first appears. With the right pieces in place (Serwist for the service worker, IndexedDB for local data storage, simple online/offline detection, and a basic sync mechanism), you can let users add, edit, and delete content without needing an active connection. When the device comes back online, those changes can be synced automatically.

This is where PWAs move beyond just showing cached pages. With a thoughtful offline-first setup, they can remain useful and reliable even when the network isn't. Once you see how these pieces fit together, it becomes much easier to build apps that people can depend on, regardless of connectivity.