Build a Next.js 16 PWA with true offline support
Progressive Web Apps are often described as apps that work offline, but in practice, many fall short of that promise. They might load once and cache a few pages, but the moment the network drops, core functionality starts to break. Data stops loading, actions fail silently, and users are left with an interface that technically opens but isn’t very useful.
If you’ve built a PWA with Next.js and felt underwhelmed by the offline experience, you’re not alone. Most tutorials stop at setting up a PWA plugin, adding a manifest, and caching static assets. That’s a good start, but it doesn’t solve the harder problems around handling real data, user actions, and state changes when the connection is unreliable or completely gone.
In this article, you’ll learn how to build a Next.js 16 PWA that keeps working when the network is slow, flaky, or offline altogether. We’ll go beyond the basic app shell approach and focus on what real offline support looks like: deciding what to cache, storing data locally, syncing changes when connectivity returns, and designing an experience that feels dependable rather than fragile.
By the end, you’ll have a clear mental model of how offline-first PWAs work in Next.js 16, how to structure your app around that model, and how to ship something users can actually rely on in low-connectivity situations, whether they’re on a plane, in a tunnel, or somewhere with spotty service.
App shell vs true offline support
When people say an app “works offline,” they’re often talking about very different things. That distinction matters because it shapes how you build the app and what users can actually do when there’s 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. Once those are cached, the app can load quickly even without a network. The shell renders, navigation works, and the experience feels fast. This is why many Progressive Web Apps can technically “open offline.”
The catch is that the app shell mostly solves loading, not functionality. As soon as the app needs real data – fetching a list, submitting a form, or updating a profile – it usually depends on the network. Without a connection, users may see empty states, errors, or stale content. The app isn’t broken, but it’s not especially useful either.
True offline support goes a step further. Instead of only caching the UI, the app can also handle data and user actions without an internet connection. It reads from local storage, saves changes locally, and queues actions to be synced later. Users can revisit content they’ve already seen, make updates, or complete tasks with the confidence that everything will sync once they’re back online.
The difference shows up clearly in real life. An app shell lets your app open on a plane. True offline support lets someone actually use it for the duration of the flight.
So when does each approach make sense?
If your app is mostly read-only, content-driven, or tightly coupled to real-time data – like a marketing site, blog, or landing page – the app shell model is usually enough. It improves performance, shortens load times, and enhances the first impression without much added complexity. If your app involves user-generated content, forms, dashboards, or workflows that people expect to work anywhere, true offline support is worth the extra effort. For note-taking apps, marketplaces, internal tools, and many mobile-first products, offline capability isn’t a bonus feature. It’s a requirement for reliability.
What are we building?
To show what true offline support looks like in practice, we’ll build a simple todo app as a Progressive Web App. The app will let users manage their tasks even when they’re offline. They’ll 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
First, create a new Next.js project:
npx create-next-app@latest nextjs-pwa-offline cd nextjs-pwa-offline
Install the PWA-related packages next. We’ll use Serwist, a modern alternative to next-pwa that’s designed to work cleanly with Next.js 16:
npm install @serwist/next @serwist/precaching @serwist/sw idb
Next, configure Next.js to enable PWA support. Create a next.config.ts file with the following setup:
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);
This tells Next.js to generate a service worker using the file located at app/sw.ts. The generated service worker is placed in the public folder and is set to be enabled only in production mode. This approach prevents caching during development, ensuring that changes are reflected without interference from cached data.
Next.js 16 uses Turbopack by default, but Serwist requires Webpack. Update your package.json:
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --webpack",
"start": "next start"
}
}
Create the service worker
Create app/sw.ts:
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();
This is the baseline setup. Serwist handles the heavy lifting for the app shell: it precaches the core files (HTML, CSS, JavaScript), applies a stale-while-revalidate strategy for assets so cached versions load immediately while updates happen in the background, and it generally prefers the network for data requests when a connection is available.
Add the PWA Manifest
Create app/manifest.ts:
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',
},
],
}
}
The manifest provides essential information for the browser about your app. It specifies the app’s name, indicates the icon that should be used, and outlines the display settings, such as whether the app should appear in standalone mode, which makes it look more like a native application.
Set up IndexedDB
This is where we store data offline. Create lib/db.ts:
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<Todo> {
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<Todo[]> {
const db = await getDB()
return db.getAll('todos')
}
export async function updateTodo(id: string, updates: Partial<Todo>): Promise<void> {
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<void> {
const db = await getDB()
await db.delete('todos', id)
}
export async function getUnsyncedTodos(): Promise<Todo[]> {
const db = await getDB()
const allTodos = await db.getAll('todos')
return allTodos.filter((todo: Todo) => !todo.synced)
}
export async function markAsSynced(id: string): Promise<void> {
const db = await getDB()
const todo = await db.get('todos', id)
if (!todo) return
todo.synced = true
await db.put('todos', todo)
}
What’s happening here, we create a database called todo-db, which has one store called todos. Each todo has an ID, text, completion status, and a synced flag. We use the idb package to make working with IndexedDB easier, as the basic API can be difficult to use.
Adding sync logic
Create lib/sync.ts:
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()
})
}
The process is simple and works well. When you are back online, the next step is to sync all the todos that haven’t synced yet. In a fully developed application, this would mean sending the data to your backend API. For this demonstration, the todos will just be marked as synced.
Build the Todo Component
Create components/TodoList.tsx:
'use client'
import { useState, useEffect } from 'react'
import { Todo, addTodo, getTodos, updateTodo, deleteTodo } from '@/lib/db'
import { syncTodos } from '@/lib/sync'
export default function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
const [newTodo, setNewTodo] = useState('')
useEffect(() => {
loadTodos()
}, [])
async function loadTodos() {
const allTodos = await getTodos()
setTodos(allTodos.sort((a, b) => b.createdAt - a.createdAt))
}
async function handleAddTodo(e: React.FormEvent) {
e.preventDefault()
if (!newTodo.trim()) return
const todo = await addTodo(newTodo.trim())
setTodos([todo, ...todos])
setNewTodo('')
// try to sync immediately
syncTodos()
}
async function handleToggle(id: string) {
const todo = todos.find(t => t.id === id)
if (!todo) return
await updateTodo(id, { completed: !todo.completed })
setTodos(todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
))
syncTodos()
}
async function handleDelete(id: string) {
await deleteTodo(id)
setTodos(todos.filter(t => t.id !== id))
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1>Offline Todos</h1>
<form onSubmit={handleAddTodo}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
<div>
{todos.map(todo => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.text}</span>
{!todo.synced && <span>Pending...</span>}
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</div>
))}
</div>
</div>
)
}
Add an offline indicator
Create components/OnlineStatus.tsx:
'use client'
import { useState, useEffect } from 'react'
export default function OnlineStatus() {
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
setIsOnline(navigator.onLine)
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 style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
background: '#f59e0b',
color: '#000',
padding: '8px',
textAlign: 'center',
}}>
You're offline - Changes will sync when you reconnect
</div>
)
}
This banner appears when you go offline. It’s small, but important users need to understand what is happening.
Putting everything together
Update app/layout.tsx:
import type { Metadata, Viewport } from 'next'
import OnlineStatus from '@/components/OnlineStatus'
export const metadata: Metadata = {
title: 'Offline Todo App',
description: 'A Next.js PWA that works completely offline',
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Offline Todos',
},
}
export const viewport: Viewport = {
themeColor: '#3b82f6',
width: 'device-width',
initialScale: 1,
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<OnlineStatus />
{children}
</body>
</html>
)
}
And app/page.tsx:
import TodoList from '@/components/TodoList'
export default function Home() {
return <TodoList />
}
To test the project, begin by building it using the command npm run build, followed by starting the server with npm start. Once the server is running, navigate to http://localhost:3000 in your web browser. Open Chrome DevTools by pressing F12 and switch to the Network tab. In the throttling dropdown, select the Offline option. With this setting, test the application’s functionality by adding todos, marking them as complete, and attempting to delete them; you’ll notice that everything continues to work smoothly. Afterward, uncheck the Offline option and observe the console to see how your todos automatically sync with the server.
How the service worker works
When you run npm run build, Serwist creates a service worker that:
- Saves your app’s basic files (HTML, CSS, JavaScript) for quick access
- Shows the saved version of most assets right away, while it updates them in the background
- Tries to get pages from the network first, and if that fails, it uses the saved version
The settings in next.config.ts take care of all this for you. You don’t need to write any service worker code yourself.
Why IndexedDB?
When you look at client-side storage options, localStorage falls short pretty quickly. It only stores strings, has a small size limit (usually around 5–10MB), and runs synchronously, which means reads and writes can block the main thread and hurt the user experience.
IndexedDB is a much better fit for application data. It can store structured objects, supports far larger storage limits (often hundreds of megabytes), and works asynchronously, so it doesn’t interfere with rendering or user interactions. It also supports indexes, which makes querying data faster and more flexible. Libraries like idb smooth out the rough edges of the native API and make IndexedDB practical to use in real apps.
Common issues to know
Service worker updates
Service workers are long-lived by design. When you ship a new version, the existing service worker keeps running while the new one installs in the background and waits until all open tabs are closed. If you want updates to take effect immediately, you can opt into that behavior with skipWaiting: true, which is what we’re using here.
HTTPS requirement
PWAs must be served over HTTPS to work correctly. The only exception is localhost, which is allowed for development. When deploying, use a platform like Vercel, Netlify, or any hosting provider that offers HTTPS by default.
iOS Safari limitations
On iOS, PWAs behave a bit differently. Users need to add the app to their home screen for PWA features to work, and some APIs aren’t available in regular Safari. Because of these constraints, it’s important to test your app on a real iOS device rather than relying only on desktop tools.
Going further
To take this offline PWA further, there are a few upgrades that make the experience feel much closer to a native app.
One big improvement is the Background Sync API. Instead of relying on a window.online listener (which only helps when the app is open), background sync lets the browser retry queued changes in the background, even after the user closes the app.
You can also add push notifications to improve feedback and engagement. For example, you could notify users when a sync finishes successfully or when new data is available.
Another important step is conflict resolution. If the same task is edited on multiple devices while offline, you need a clear rule for how to handle it. The simplest option is last write wins, but you can also merge changes when possible, or prompt the user to choose which version to keep when accuracy matters more than convenience.
Finally, you can introduce a scheduled background refresh. Syncing at regular intervals helps keep data fresh without requiring the user to open the app, which is especially useful for apps that people expect to “just stay updated.”
Conclusion
Building a truly offline-capable PWA is more approachable than it first appears. With the right pieces in place – Serwist to handle 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.
The post Build a Next.js 16 PWA with true offline support appeared first on LogRocket Blog.
This post first appeared on Read More


