# Nextjs PWA Without next-pwa: Offline Page and App Shell

> Build a Nextjs PWA on Next.js 16 without next-pwa: app/manifest.ts, a hand-written offline service worker, an install prompt and an app shell with safe areas.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 5, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/nextjs-pwa

## TL;DR

A Nextjs PWA on the App Router needs four files of your own: app/manifest.ts with the name, icons and standalone display, a self-contained public/offline.html, a small public/sw.js that serves it when a page load fails, and a client component that registers the worker. Skip next-pwa on Next.js 16: it is a webpack plugin and next build now runs on Turbopack, so write the worker by hand and move to Serwist only when you need cached routes and assets. Set viewportFit cover in the viewport export, then add an install prompt and an app shell whose top bar and bottom tabs pad the safe areas.

A Nextjs PWA is a normal App Router app plus a few files: a web app manifest, icons, a service worker and a layout that still looks right when the browser chrome is gone. The official guide covers the manifest and push notifications, and many tutorials then install `next-pwa`, a webpack plugin, into a framework that now builds with Turbopack by default. This tutorial takes a smaller path for Next.js 16: a hand-written worker that shows an offline page, an install prompt, and an app shell with a top bar and bottom tabs that clear the notch and the home indicator.

## What does a Nextjs PWA need in 2026?

Less than older guides suggest. Here is the whole list for a Nextjs PWA App Router setup, with what each piece buys you:

| Piece | File | Needed to install? | What it gives you |
| --- | --- | --- | --- |
| Web app manifest | `app/manifest.ts` | yes | name, icons, `standalone` display, start URL |
| Icons | `public/icon-192.png`, `public/icon-512.png`, `app/apple-icon.png` | yes | home screen and task switcher icons |
| HTTPS | your host | yes (localhost is exempt) | a secure context for the worker |
| Service worker | `public/sw.js` + a register component | no | an offline page, push, Chrome's automatic install event |
| Viewport and theme | `export const viewport` in `app/layout.tsx` | no | edge-to-edge drawing, a colored status bar |
| Install prompt | a client component in the root layout | no | your own Install button and iPhone steps |
| App shell | `app/(app)/layout.tsx` | no | top bar, bottom tabs, safe areas |

The installability rules per browser, including why Chrome may hold back its install event on a first visit, are in our [React PWA install prompt](https://wingo-ui.com/blog/react-pwa-install-prompt) guide, so this post does not repeat them.

## How do you add a web app manifest in the App Router?

With the `manifest` file convention. An `app/manifest.ts` that default exports a function is served at `/manifest.webmanifest` and linked from every page's `<head>` for you:

```ts
// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    id: "/",
    name: "Northwind CRM",
    short_name: "Northwind",
    description: "Clients, quotes and invoices on your phone.",
    start_url: "/",
    scope: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#ffffff",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
      { src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
  };
}
```

Put the three PNGs in `public/`. The maskable one keeps its logo inside the middle 80% so Android can crop it into a circle or a squircle. For iPhone, drop a 180x180 `app/apple-icon.png` next to the layout; Next.js turns it into the `apple-touch-icon` link.

Then tell the browser how the installed app should draw. `viewport` and `metadata` are separate exports in the App Router, and both only work in Server Components:

```tsx
// app/layout.tsx
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { RegisterServiceWorker } from "./register-sw";
import "./globals.css";

export const metadata: Metadata = {
  title: "Northwind CRM",
  appleWebApp: { capable: true, title: "Northwind", statusBarStyle: "black-translucent" },
};

export const viewport: Viewport = {
  // lets the page draw under the notch and the home indicator; the shell pads them back
  viewportFit: "cover",
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "#ffffff" },
    { media: "(prefers-color-scheme: dark)", color: "#0b0b0f" },
  ],
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <RegisterServiceWorker />
      </body>
    </html>
  );
}
```

`viewportFit: "cover"` matters more than it looks. Without it, iOS letterboxes the page and `env(safe-area-inset-*)` stays 0, so any component that pads the safe areas has nothing to pad. With it, the page runs under the status bar and the home indicator, and your header and tab bar must add that space back. The shell section below does exactly that.

One iPhone catch: `statusBarStyle: "black-translucent"` is what lets the installed app draw under the status bar, but it keeps the clock and battery icons white. Give the top bar a dark enough fill in light mode, or use `"default"` for an opaque status bar above the page, in which case the top inset is simply 0.

## Do you still need next-pwa for a Next.js 16 PWA?

No. The original [next-pwa](https://www.npmjs.com/package/next-pwa) plugin's latest version, 5.6.0, was published in August 2022, and the maintained fork [@ducanh2912/next-pwa](https://www.npmjs.com/package/@ducanh2912/next-pwa) last shipped 10.2.9 in September 2024 (npm registry, checked November 5, 2026). Both hook into webpack, while Next.js 16 runs `next dev` and `next build` on Turbopack unless you pass `--webpack`. The [Next.js 16 upgrade guide](https://nextjs.org/docs/app/guides/upgrading/version-16) says `next build` fails when it finds a webpack config, and a plugin that adds one counts. So a Nextjs 16 PWA built on next-pwa either stays on webpack with `--webpack` or ships without a generated worker under `next build --turbopack`, which ignores the config.

Two options work with Turbopack:

- **A hand-written worker** for an offline page and push. It is one file in `public/`, no build step, no dependency. This post builds it.
- **[Serwist](https://serwist.pages.dev/docs/next/turbo)** when you want to precache routes and cache assets with Workbox-style strategies. Its `@serwist/turbopack` package (9.5.13, October 4, 2026) serves the worker from a route handler built with `createSerwistRoute`, compiles `app/sw.ts` with esbuild, and wraps the tree in a `SerwistProvider`. The [Next.js PWA guide](https://nextjs.org/docs/app/guides/progressive-web-apps) points to Serwist for full offline caching too.

Start with the hand-written worker. Move to Serwist the day you need cached data screens, not before, because every cached asset is a version you have to expire.

## How do you show an offline page in a Nextjs PWA?

Catch failed page loads in the worker and answer them with a page you cached at install time. This is the [offline fallback page](https://web.dev/articles/offline-fallback-page) recipe from web.dev, adapted to Next.js. It is the cheapest Nextjs PWA offline setup that still feels like an app instead of the browser's dinosaur.

### 1. A self-contained offline page

Do not make the offline page a Next.js route. Its CSS and JavaScript live in hashed files under `/_next/static` that your worker never cached, so offline it would load as unstyled HTML. Write plain HTML with inline styles instead:

```html
<!-- public/offline.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <title>You are offline</title>
    <style>
      :root { color-scheme: light dark; --bg: #ffffff; --fg: #0b0b0f; --muted: #5b5b66; }
      @media (prefers-color-scheme: dark) { :root { --bg: #0b0b0f; --fg: #f5f5f7; --muted: #a1a1aa; } }
      body { margin: 0; min-height: 100dvh; display: grid; place-items: center; background: var(--bg); color: var(--fg);
        font: 16px/1.5 system-ui, sans-serif; padding: env(safe-area-inset-top) 24px env(safe-area-inset-bottom); }
      main { max-width: 22rem; text-align: center; }
      p { color: var(--muted); }
      button { min-height: 44px; padding: 0 20px; border: 0; border-radius: 12px; background: var(--fg); color: var(--bg);
        font: inherit; font-weight: 600; touch-action: manipulation; }
    </style>
  </head>
  <body>
    <main>
      <h1>You are offline</h1>
      <p>Northwind needs a connection to load this page. It reloads on its own when you are back online.</p>
      <button type="button" onclick="location.reload()">Try again</button>
    </main>
    <script>
      // reload on its own when the connection comes back
      addEventListener("online", () => location.reload());
    </script>
  </body>
</html>
```

### 2. The service worker

```js
// public/sw.js
const CACHE = "offline-v1";
const OFFLINE_URL = "/offline.html";

self.addEventListener("install", (event) => {
  // cache: "reload" skips the HTTP cache, so the page comes fresh from the server
  event.waitUntil(caches.open(CACHE).then((cache) => cache.add(new Request(OFFLINE_URL, { cache: "reload" }))));
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) await self.registration.navigationPreload.enable();
      const keys = await caches.keys();
      await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)));
      await self.clients.claim();
    })(),
  );
});

self.addEventListener("fetch", (event) => {
  // only full page loads; RSC payloads, images and API calls go straight to the network
  if (event.request.mode !== "navigate") return;
  event.respondWith(
    (async () => {
      try {
        const preloaded = await event.preloadResponse;
        if (preloaded) return preloaded;
        return await fetch(event.request);
      } catch {
        // a network error, not a 404 or 500: those still reach the page
        const cache = await caches.open(CACHE);
        return (await cache.match(OFFLINE_URL)) ?? Response.error();
      }
    })(),
  );
});
```

`skipWaiting()` and `clients.claim()` are safe here because this worker serves no app code. Once a worker caches your JavaScript chunks, activating a new one under an open tab can mix old and new files, and you need an "Update available" prompt instead. Bump `CACHE` to `offline-v2` whenever `offline.html` changes; the activate step deletes the old cache.

### 3. Register it from a client component

```tsx
// app/register-sw.tsx
"use client";

import { useEffect } from "react";

export function RegisterServiceWorker() {
  useEffect(() => {
    // dev rebuilds constantly; test the worker with next build && next start
    if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return;
    navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" }).catch(() => {});
  }, []);
  return null;
}
```

`updateViaCache: "none"` makes the update check that runs on navigations fetch `sw.js` from the server instead of the HTTP cache. Back it with a header so CDNs do not hold an old worker either:

```ts
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/sw.js",
        headers: [
          { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
          { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
        ],
      },
    ];
  },
};

export default nextConfig;
```

### 4. Test it

Run `next build && next start` and open `http://localhost:3000`. Localhost is a secure context, so no certificate is needed. In Chrome DevTools, Application lists the manifest and the active worker. Switch the Network panel to Offline and reload: you get your page instead of the browser's error.

## What happens to client-side navigations when the network drops?

They fail like any other request, because the worker above only sees full page loads. A tap on a `<Link>` fetches a React Server Components payload with `fetch()`, not a navigation request, so the worker lets it through to the network. Next.js 16 has an answer for that layer, still marked experimental: with `experimental.useOffline: true` in `next.config.ts`, a failed navigation, prefetch or Server Action stays pending and retries when the connection returns, and the `useOffline()` hook from `next/offline` tells your UI it is offline. The [offline support guide](https://nextjs.org/docs/app/guides/offline-support) shows a banner in the root layout built on it. Treat it as a nice extra for now and keep the worker as the floor, since an experimental flag can change between minor versions.

## How do you add an install prompt to a Next.js PWA?

Mount one component in the root layout, because Chrome fires `beforeinstallprompt` early in the page load and a component that mounts later on one route can miss it. The full story, from `beforeinstallprompt` to the iPhone's Share sheet and when not to ask, is in [React PWA install prompt for Chrome, Edge and iPhone](https://wingo-ui.com/blog/react-pwa-install-prompt). The short version with our [Install Prompt](https://wingo-ui.com/components/install-prompt):

```tsx
// in app/layout.tsx, next to <RegisterServiceWorker />
import { InstallPrompt } from "@/components/ui/install-prompt";

<InstallPrompt
  appName="Northwind CRM"
  appIcon="/icon-192.png"
  description="Open it right from your home screen, even offline."
/>
```

It shows the browser's install dialog in Chrome and Edge, the Add to Home Screen steps on iPhone, stays hidden inside the installed app and in in-app browsers, and remembers a dismissal for 14 days by default. On phones it sits above the bottom tab bar from the next section, because it reads the bar's `--bottom-bar-offset`. The offline page from the previous section is what makes "even offline" an honest promise.

## How do you make a Nextjs PWA feel native on phones?

Give the installed app its own chrome. In `standalone` mode there is no address bar and no back button, so the app has to supply a header, a way back and primary navigation within thumb reach. On phones that means a top bar and a bottom tab bar, both padded for the safe areas you opened up with `viewportFit: "cover"`.

The [App Shell](https://wingo-ui.com/components/app-shell) does this as one layout: a sidebar and top bar on desktop, a drawer and a [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) below 768px. Resize the demo below to a phone width, or open it on your phone, and switch tabs at the bottom:

> Live demo (App Shell): The same shell on desktop and phone: shrink the window under 768px and the sidebar turns into a drawer behind the menu button, with tabs at the bottom. Try it at [App Shell](https://wingo-ui.com/components/app-shell) and install it with `npx wingo-ui@latest add app-shell`.

Install it with `npx wingo-ui@latest add app-shell`, which also copies the Sidebar, Topbar and Bottom Tab Bar it imports. Then make it the layout of the signed-in routes. First, a provider so every tab and sidebar link renders through `next/link`:

```tsx
// app/providers.tsx
"use client";

import Link from "next/link";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { LinkProvider } from "@/lib/navigation";

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter();
  return (
    <LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}>
      {children}
    </LinkProvider>
  );
}
```

Wrap `{children}` in the root layout with `<Providers>`, then build the shell:

```tsx
// app/(app)/layout.tsx
import type { ReactNode } from "react";
import { FileText, House, Inbox, Settings, Users } from "lucide-react";
import { AppShell } from "@/components/ui/app-shell";
import type { SidebarNavGroup } from "@/components/ui/sidebar";
import { Topbar } from "@/components/ui/topbar";
import type { NavItem } from "@/lib/navigation";

const nav: SidebarNavGroup[] = [
  {
    items: [
      { label: "Home", href: "/", icon: <House /> },
      { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
    ],
  },
  {
    label: "Sales",
    items: [
      { label: "Clients", href: "/clients", icon: <Users /> },
      { label: "Invoices", href: "/invoices", icon: <FileText /> },
    ],
  },
  { label: "Account", items: [{ label: "Settings", href: "/settings", icon: <Settings /> }] },
];

// four tabs: the phone's primary destinations
const tabs: NavItem[] = [
  { label: "Home", href: "/", icon: <House /> },
  { label: "Clients", href: "/clients", icon: <Users /> },
  { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
  { label: "Settings", href: "/settings", icon: <Settings /> },
];

export default function AppLayout({ children }: { children: ReactNode }) {
  return (
    <AppShell nav={nav} topbar={<Topbar title="Northwind" />} tabs={tabs}>
      {children}
    </AppShell>
  );
}
```

Three details make it feel installed rather than embedded:

- **Safe areas.** The Topbar pads `env(safe-area-inset-top)` and the Bottom Tab Bar pads `env(safe-area-inset-bottom)`, both on by default (`safeArea`). Our [Tailwind safe area insets](https://wingo-ui.com/blog/tailwind-safe-area-insets) post covers the rest of the page.
- **Real viewport height.** The shell sizes itself with `dvh` instead of `vh`, so a short page ends at the visible bottom edge instead of behind Safari's toolbar. The background is in [100vh on mobile and dvh in Tailwind](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind).
- **Window scroll.** The default `scroll="window"` keeps router scroll restoration working, and the fixed tab bar writes `--bottom-bar-offset` so floating buttons clear it.

### Hide browser-only UI in the installed app

A marketing footer or a "Get the app" link looks odd inside the app itself. Add a Tailwind v4 variant for the display mode in `app/globals.css`:

```css
@custom-variant standalone (@media (display-mode: standalone));
```

Then `className="standalone:hidden"` removes an element only in the installed app, and `standalone:pb-4` can add room that only matters there. It is CSS, so it is right on the first paint, with no hydration flash.

### Start from a full home screen

For a consumer app, the [Mobile App Home](https://wingo-ui.com/components/mobile-app-home) block is a complete first screen built on the same tab bar: a large title that shrinks into the bar, search, stories, quick actions, feed tabs and pull to refresh. Scroll it, pull down at the top and switch feed tabs:

> Live demo (Mobile App Home): A marketplace home screen as an installed PWA would show it: scroll to shrink the title, pull down to refresh, tap the tabs at the bottom. Try it at [Mobile App Home](https://wingo-ui.com/components/mobile-app-home) and install it with `npx wingo-ui@latest add mobile-app-home`.

Its `desktop="full"` prop stretches it on a tablet instead of keeping a centered 440px column, which suits an installed app on an iPad.

## What should you check before shipping the PWA?

Six checks, run on real devices where you can, because DevTools cannot show you the notch, the launcher icon or airplane mode:

1. **Manifest.** DevTools, Application, Manifest shows no errors, and both icon sizes load.
2. **Offline.** Install the app, turn on airplane mode, open it: you see `offline.html`, and it reloads once you reconnect.
3. **iPhone.** Add it to the Home Screen from Safari. The status bar text is readable over your top bar, nothing sits under the notch, and the tab bar clears the home indicator.
4. **Android.** Install from Chrome, check the maskable icon in the launcher, and rotate the phone once.
5. **Updates.** Change `offline.html`, bump `CACHE`, deploy, reopen the app twice: the new page shows and DevTools lists one cache.
6. **Desktop.** Install from Chrome or Edge; the shell should show the sidebar layout in its own window.

If you came looking for a Nextjs PWA template, run this checklist against it before you adopt it; step 3 is the one a desktop browser cannot test for you.

## What should you build next?

Ship the four files first: `app/manifest.ts`, `public/offline.html`, `public/sw.js` and the register component. They are plain Next.js, free, and give you an installable app with an honest offline state. For the installed-app layer, `npx wingo-ui@latest add app-shell install-prompt` copies the shell, the bottom tabs and the install banner into your project as source; they are part of [Wingo UI Pro](https://wingo-ui.com/pricing) ($8 a month, $80 a year or $150 once). More patterns for apps people hold in one hand are in the pillar on [mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components) and the [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui) category, and [React Native vs PWA](https://wingo-ui.com/blog/react-native-vs-pwa) helps if you are still deciding whether the web is enough.

## Components in this post

- [Install Prompt](https://wingo-ui.com/components/install-prompt) (Pro): A React PWA install prompt: the browser's install dialog in Chrome and Edge, and an Add to Home Screen guide on iPhone and Mac. Install: `npx wingo-ui@latest add install-prompt`
- [App Shell](https://wingo-ui.com/components/app-shell) (Pro): A React app shell for admin layouts: a collapsible, resizable sidebar, a top bar and the page, with a drawer and a tab bar on phones. Install: `npx wingo-ui@latest add app-shell`
- [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) (Pro): A React bottom tab bar for phones: 3 to 5 tabs at the thumb with an animated pill, badges, a raised center action and hide on scroll. Install: `npx wingo-ui@latest add bottom-tab-bar`
- [Mobile App Home](https://wingo-ui.com/components/mobile-app-home) (Pro): A React mobile app home screen for a marketplace: large title, search, stories, quick actions, recommended ads, feed tabs and a tab bar. Install: `npx wingo-ui@latest add mobile-app-home`

## FAQ

### Does next-pwa work with Next.js 16?

Not by default. The original next-pwa package is a webpack plugin whose latest release, 5.6.0, dates from August 2022, and Next.js 16 builds with Turbopack, so next build stops on the webpack config the plugin adds unless you pass --webpack. Use a hand-written service worker for an offline page, or Serwist, whose @serwist/turbopack package (9.5.13, October 2026) has a Turbopack setup in its docs.

### Does a Next.js PWA need a service worker to be installable?

Not in current Chromium browsers or on iPhone: a manifest with a name, 192px and 512px icons, a start_url and a standalone display, served over HTTPS, is enough to install from the browser menu. You still want one for an offline page, push notifications and the automatic beforeinstallprompt event in Chrome.

### Why does my offline page in Next.js show up without styles?

The page's CSS and JavaScript live in hashed files under /_next/static that were never cached by your worker, so offline the HTML loads and its stylesheet fails. Keep the offline page self-contained with inline CSS in public/offline.html, as web.dev's offline fallback recipe does.

### How do I test a Next.js service worker locally?

Run next build and next start, then open http://localhost:3000: localhost counts as a secure context, so the worker registers without HTTPS. In Chrome DevTools, Application shows the manifest and the worker, and the Network panel's Offline option lets you reload into the offline page.

### Is there a Next.js PWA template with an app shell?

The Wingo UI App Shell, Bottom Tab Bar, Install Prompt and Mobile App Home block give you the installed-app layer: top bar, bottom tabs with safe areas, a drawer on phones and an install banner. They are Pro items, installed as source with npx wingo-ui@latest add.

---

Source: https://wingo-ui.com/blog/nextjs-pwa
