Static Export, Single-Page Apps & Progressive Web Apps
Run Next.js without a server and make it installable: when a static export is the right choice, output: 'export' and what it supports, images and dynamic routes in static builds, hosting on S3, GitHub Pages and Nginx, building SPAs with Next.js, migrating a Vite or Create React App project, and turning your app into a PWA with a manifest, service worker, push notifications and offline support.
Do You Need a Server at All?
Everything in this course so far ran on a Node.js server (or serverless functions): Server Actions, Proxy, ISR, Draft Mode, on-demand image optimization, cookies.
But many sites don't need any of that at request time โ a portfolio, documentation, a landing page, a course site, a client-side dashboard that talks to an external API. For those, Next.js can produce a static export: plain HTML, CSS and JavaScript files you can host anywhere โ S3, GitHub Pages, any CDN, a basic Nginx server โ often for free.
Choose a static export when:
- All pages can be generated at build time (or rendered in the browser).
- Data comes from build-time sources or an external API called from the client.
- You need the cheapest, simplest, most portable hosting.
Choose a Node.js server (or Vercel / Docker) when you need Server Actions, authentication with cookies, Proxy redirects, ISR/revalidation, Draft Mode or image optimization on the fly.
Enabling a Static Export
Set output: "export" in next.config.js and run next build. Next.js writes a fully static site to the out/ folder โ one HTML file per route, plus assets.
Optional settings:
trailingSlash: trueโ emit/about/index.htmlinstead of/about.html, so URLs like/about/work on hosts that serve folders (GitHub Pages, S3 website hosting).distDirโ change the output folder name (e.g."dist").basePathโ serve the site from a sub-path, e.g."/my-repo"for GitHub Pages project sites.
Run npx serve out to preview the exported site locally exactly as a static host would serve it.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
// trailingSlash: true, // /about โ /about/index.html
// distDir: "dist", // default is "out"
// basePath: "/my-repo", // for https://username.github.io/my-repo
images: { unoptimized: true }, // see section 4
};
export default nextConfig;
# Build and preview
npm run build # โ out/index.html, out/about.html, out/_next/static/...
npx serve out # preview at http://localhost:3000What Works in a Static Export
More than you might expect โ the App Router's model maps naturally to static output:
- Server Components โ run at build time; their output becomes static HTML and RSC payloads. You can still query a database or call an API during the build.
- Client Components โ work normally in the browser (state, effects, events).
- Dynamic routes โ supported when
generateStaticParamslists every path. <Link>prefetching, client-side navigation and code splitting.- Route Handlers โ only
GEThandlers that don't read the request; they're rendered to static files at build time (e.g.app/feed.xml/route.jsโout/feed.xml). - Metadata files โ
sitemap.js,robots.js,manifest.js,opengraph-image.js(when static). - Client-side data fetching with SWR or TanStack Query for anything that must be fresh.
- All styling options,
next/font,next/script,next/dynamic.
What Doesn't Work (and the Alternatives)
Anything that needs a server at request time is unavailable:
- Server Actions โ call an external API (or a serverless function on another platform) from Client Components.
- Proxy,
redirects,rewrites,headersin next.config โ configure redirects and headers on your host/CDN (S3 redirect rules, Netlify_redirects, Nginx config). - Cookies,
headers(), request-dependent Route Handlers โ handle auth in the browser with a third-party auth provider's client SDK. - Dynamic routes without
generateStaticParams, or withdynamicParams: trueโ list every path, or render the variable part client-side. - ISR / revalidation / Draft Mode โ rebuild and redeploy when content changes (a CMS webhook can trigger your CI build).
- Intercepting routes โ not supported.
- Image optimization with the default loader โ use a custom loader that points at an image CDN (Cloudinary, Imgix, Cloudflare Images), or set
images.unoptimized: trueand optimize images yourself before committing them.
If you use an unsupported feature, next build fails with an error that tells you which one โ so you find out at build time, not in production.
// next.config.mjs โ custom image loader for static export
const nextConfig = {
output: "export",
images: { loader: "custom", loaderFile: "./lib/image-loader.js" },
};
export default nextConfig;
// lib/image-loader.js โ Cloudinary example
export default function cloudinaryLoader({ src, width, quality }) {
const params = ["f_auto", "c_limit", "w_" + width, "q_" + (quality || "auto")];
return "https://res.cloudinary.com/demo/image/upload/" + params.join(",") + src;
}Dynamic Routes and Data in a Static Export
For a static export, every URL must exist as a file after the build:
- Export
generateStaticParamsfrom every dynamic segment and return all paths. - Unknown paths should 404 โ the host serves your
404.html(generated fromnot-found.js). - Data fetched in Server Components is frozen at build time. That's perfect for blog posts and docs; for prices, stock or user data, fetch on the client.
This portfolio's notes section is a good example of export-friendly design: all lectures come from local data files and generateStaticParams lists every lecture, so every notes page is prebuilt as static HTML.
// app/notes/[techStack]/[lectureSlug]/page.js โ export-friendly
export const dynamicParams = false; // only the listed paths exist
export function generateStaticParams() {
return getAllTechStacks().flatMap((stack) =>
stack.lectures.map((l) => ({ techStack: stack.slug, lectureSlug: l.slug }))
);
}
// A fresh value fetched in the browser on a static page
"use client";
import useSWR from "swr";
export function LiveStars() {
const { data } = useSWR("https://api.github.com/repos/vercel/next.js", (u) => fetch(u).then((r) => r.json()));
return <span>โญ {data ? data.stargazers_count.toLocaleString() : "โฆ"}</span>;
}Hosting a Static Export
Upload the out/ folder to any static host:
- GitHub Pages โ free; use
basePathfor project sites andtrailingSlash: true. Add an empty.nojekyllfile so folders starting with_(like_next) are served. - AWS S3 + CloudFront โ cheap and fast at scale; set the 404 page to
404.html. - Netlify / Cloudflare Pages โ connect the repo, build command
npm run build, publish directoryout. - Your own Nginx server โ serve
out/and map clean URLs to.htmlfiles.
Remember: redirects, security headers and caching rules now live in the host's configuration, not next.config.js.
# nginx.conf โ serve a Next.js static export with clean URLs
server {
listen 80;
server_name example.com;
root /var/www/out;
location / {
try_files $uri $uri.html $uri/ =404; # /about โ about.html
}
# Long cache for hashed build assets
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
error_page 404 /404.html;
}Building Single-Page Applications with Next.js
A strict SPA renders entirely in the browser and fetches data from an API. Why use Next.js for that instead of plain Vite?
- Automatic code splitting per route โ users download only the JavaScript for the page they're on, instead of one giant bundle.
- Fast first paint โ even client-heavy pages get prerendered HTML shells at build time.
- File-system routing,
next/image,next/font, metadata โ included. - A path to grow โ when you later need server features, they're already there; you migrate route by route.
Next.js SPAs work with or without a static export: start fully client-side and add server rendering only where it pays off.
SPA Patterns: Client Data, Browser-Only Code and Shallow Routing
Common patterns from the official SPA guide:
- Client data libraries โ SWR or TanStack Query handle caching and revalidation in the browser.
- Start fetching early โ kick off a request in a Server Component (or root layout) and pass the promise through Context; components read it with
use()inside<Suspense>. - Browser-only components โ wrap components that touch
windowon first render withnext/dynamicand{ ssr: false }(inside a Client Component). - Shallow routing โ update the URL without a navigation using the native
window.history.pushState/replaceState. Next.js integrates these withusePathnameanduseSearchParams, so your components stay in sync โ ideal for filters, tabs and sort orders.
"use client";
import { useSearchParams } from "next/navigation";
export default function SortProducts() {
const searchParams = useSearchParams();
function updateSort(order) {
const params = new URLSearchParams(searchParams.toString());
params.set("sort", order);
window.history.pushState(null, "", "?" + params.toString()); // no server request
}
return (
<>
<button onClick={() => updateSort("asc")}>Sort ascending</button>
<button onClick={() => updateSort("desc")}>Sort descending</button>
<p>Current: {searchParams.get("sort") ?? "none"}</p> {/* stays in sync */}
</>
);
}Migrating a Vite or Create React App Project
Create React App is deprecated, and many Vite SPAs eventually need SEO or server features. The official migration strategy is incremental:
- Install Next.js and add
output: "export"so behaviour stays SPA-like. - Create a root layout (
app/layout.js) with the contents of your oldindex.html<head>. - Create an optional catch-all route
app/[[...slug]]/page.jsthat renders your entire existing app as a client-only component (next/dynamicwithssr: false). Your React Router setup keeps working. - Run it โ the app works exactly as before, now built by Next.js.
- Move routes one at a time from React Router into real
app/routes, gaining code splitting and server rendering page by page. - When everything is migrated, remove React Router and the catch-all โ and drop
output: "export"if you want server features.
// app/[[...slug]]/page.js โ catches every URL during migration
import "../../index.css";
import { ClientOnly } from "./client";
export function generateStaticParams() {
return [{ slug: [""] }]; // generate a single entry page
}
export default function Page() {
return <ClientOnly />;
}
// app/[[...slug]]/client.js
"use client";
import dynamic from "next/dynamic";
const App = dynamic(() => import("../../App"), { ssr: false }); // your existing SPA root
export function ClientOnly() {
return <App />;
}What Makes a Progressive Web App (PWA)?
A PWA is a website that can be installed on phones and desktops and behaves like a native app: its own icon on the home screen, a standalone window without browser UI, push notifications, and (optionally) offline support.
The building blocks:
- HTTPS โ required for service workers and push.
- Web App Manifest โ name, icons, colours, display mode. Enables "Install app" / "Add to Home Screen".
- Service worker โ a script that runs in the background, separate from the page. It can receive push messages, cache assets and serve the app offline.
Why teams choose PWAs: one codebase for web, Android and desktop; no app-store review or fees for updates; instant updates on deploy. iOS supports installed PWAs and web push (iOS 16.4+, for apps added to the Home Screen).
Note: installability doesn't require offline support โ a manifest plus HTTPS is enough for an install prompt in most browsers.
Step 1: The Web App Manifest
Create app/manifest.js (Lecture 11) โ Next.js serves it at /manifest.webmanifest and links it automatically.
Provide at least a 192ร192 and a 512ร512 PNG icon; add a maskable icon so Android can crop it into any shape. Choose display: "standalone" for an app-like window.
// app/manifest.js
export default function manifest() {
return {
name: "Ravindra Nath Jha โ Dev Notes",
short_name: "Dev Notes",
description: "Free Next.js, React and JavaScript lecture notes",
start_url: "/notes",
display: "standalone",
background_color: "#f7f7f7",
theme_color: "#002057",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}Step 2: Web Push Notifications
Push lets you notify users even when the site isn't open. The pieces:
- VAPID keys identify your server to push services. Generate once with
npx web-push generate-vapid-keys; store the public key inNEXT_PUBLIC_VAPID_PUBLIC_KEYand the private key inVAPID_PRIVATE_KEY(server only). - Register a service worker (
public/sw.js) and subscribe the user withregistration.pushManager.subscribe()โ only after a clear user action (a button), never on page load. - Store the subscription on the server (a Server Action saving it to your database).
- Send notifications from the server with the
web-pushlibrary. - The service worker's
pushevent shows the notification;notificationclickopens the right page.
Test locally over HTTPS with next dev --experimental-https.
// app/actions/push.js
"use server";
import webpush from "web-push";
webpush.setVapidDetails(
"mailto:you@example.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
export async function subscribeUser(subscription) {
await db.pushSubscription.create({ data: { endpoint: subscription.endpoint, json: JSON.stringify(subscription) } });
return { ok: true };
}
export async function sendNotification(subscriptionJson, message) {
await webpush.sendNotification(
JSON.parse(subscriptionJson),
JSON.stringify({ title: "New lecture published", body: message, icon: "/icons/icon-192.png" })
);
}
// public/sw.js
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(self.registration.showNotification(data.title, { body: data.body, icon: data.icon }));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow("/notes"));
});Subscribing from the Client
A Client Component registers the service worker, asks for permission when the user clicks, subscribes, and sends the subscription to the Server Action. Check for support first โ not every browser (or every iOS context) supports push.
The service worker file should never be cached long-term, so users always get the latest version. Set headers for /sw.js in next.config.js (on a static export, configure them on your host instead).
// app/components/PushToggle.jsx
"use client";
import { useEffect, useState } from "react";
import { subscribeUser } from "@/app/actions/push";
function urlBase64ToUint8Array(base64) {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const raw = atob((base64 + padding).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
export default function PushToggle() {
const [supported, setSupported] = useState(false);
const [subscribed, setSubscribed] = useState(false);
useEffect(() => {
if ("serviceWorker" in navigator && "PushManager" in window) {
setSupported(true);
navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
}
}, []);
async function subscribe() {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
});
await subscribeUser(JSON.parse(JSON.stringify(sub))); // plain, serializable object
setSubscribed(true);
}
if (!supported) return <p>Notifications aren't supported in this browser.</p>;
return subscribed ? <p>๐ You'll be notified about new lectures.</p> : <button onClick={subscribe}>Notify me</button>;
}
// next.config.mjs โ headers for the service worker
// async headers() {
// return [{ source: "/sw.js", headers: [
// { key: "Content-Type", value: "application/javascript; charset=utf-8" },
// { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
// { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
// ]}];
// }Offline Support and Caching Strategies
To work offline, the service worker must cache your app's files and data and serve them when the network is unavailable. Common strategies:
- Cache first โ serve from cache, fall back to network. For versioned static assets (
/_next/static/*, fonts, icons). - Network first โ try the network, fall back to cache. For HTML pages and API data that should be fresh but still work offline.
- Stale-while-revalidate โ serve from cache instantly and update it in the background. For content like lecture pages.
- Offline fallback page โ a friendly "You're offline" page for routes that were never cached.
Writing this by hand is error-prone. Serwist (the maintained successor to next-pwa) generates a service worker with precaching and runtime caching for Next.js. Note: at the time of writing its Next.js plugin requires a Webpack build, so with Next.js 16 you'd build with next build --webpack โ check Serwist's docs for current Turbopack support.
Be careful what you cache: never cache authenticated, per-user API responses in a shared cache, and version your caches so old assets are cleaned up after deployments.
Practical: An Installable, Static Notes App
Turn a notes/documentation site into a fast, installable app that costs nothing to host:
output: "export"withgenerateStaticParamsfor every lecture;trailingSlash: true.images.unoptimized: true(or an image CDN loader).app/manifest.jswith icons anddisplay: "standalone"โ install prompt on Android and desktop.- A service worker with stale-while-revalidate caching for lecture pages โ readable offline on the train.
- Deploy
out/to GitHub Pages or Cloudflare Pages; configure headers there.
Then check it with Lighthouse (it reports PWA installability) and in DevTools โ Application (Manifest, Service Workers, Cache Storage).
next.config.mjs // output: "export", trailingSlash: true, images.unoptimized: true
app/manifest.js // name, icons (192, 512, maskable), display: "standalone", theme_color
app/notes/.../page.js // generateStaticParams + dynamicParams = false
public/sw.js // (or Serwist-generated) precache + stale-while-revalidate for /notes/*
public/icons/*.png
app/components/RegisterSW.jsx // "use client" โ navigator.serviceWorker.register("/sw.js")
# Build, preview, deploy
npm run build && npx serve out
# upload out/ to your static hostSummary
- A static export (
output: "export") builds plain files intoout/that any static host can serve โ ideal for portfolios, docs and client-side apps. - Server Components run at build time; Client Components,
<Link>, static GET Route Handlers and metadata files all work. - Unavailable: Server Actions, Proxy, next.config redirects/rewrites/headers, cookies, ISR, Draft Mode, intercepting routes and default image optimization โ use host config, client-side APIs, rebuilds and image CDN loaders instead.
- Every dynamic path needs
generateStaticParams; fresh data is fetched on the client. - Next.js makes better SPAs thanks to per-route code splitting; migrate Vite/CRA apps incrementally via an optional catch-all route with a client-only app.
- A PWA needs HTTPS, a manifest (
app/manifest.js) and a service worker; add web push with VAPID keys andweb-push, and offline support with caching strategies (e.g. via Serwist).
๐ Course complete! With these 18 lectures you've covered Next.js 16 from setup to production: routing, rendering, data, caching, mutations, APIs, styling, SEO, security, databases, content, motion, internationalization and deployment โ static, serverless or self-hosted. Now build something and ship it.