Turn a Next.js 16 app into plain HTML, CSS and JS with output export, learn what stops working, and publish it to GitHub Pages with basePath.

Bishal Biswas
Self Learner
A website with no server at all
Everything Book Nook does so far needs a running Node.js server: reading cookies, saving reviews, logging in, refreshing cached data. But many websites do not need any of that. A portfolio, a documentation site, a small catalogue or a landing page only shows the same content to everyone.
For those, Next.js can do a static export: during next build it renders every page to plain HTML, CSS and JavaScript files and puts them in a folder called out. You can then upload that folder to any web host that serves files: GitHub Pages, an S3 bucket, a cheap shared host, or an nginx server. No Node.js required.
Think of it like printing a magazine. A server-rendered app is a journalist who writes a fresh answer for every reader. A static export prints all the pages once, and then anyone can hand out copies. You lose the journalist’s ability to answer personal questions, but copies are cheap, fast and work everywhere.
In this lesson you will:
- Turn on
output: 'export'and see what comes out. - Learn which features stop working, and why.
- Handle the Cache Components problem in our course project.
- Publish a “Book Nook catalogue” to GitHub Pages with
basePath.
Step 1: what changes in a static export
Server Components still work. They simply run once, during the build, instead of on every request. So this page:
// app/books/page.js
import Link from 'next/link';
import { getBooks } from '@/lib/books';
export default async function BooksPage() {
const books = await getBooks(); // runs during next build
return (
<section>
<h1 className="text-3xl font-bold">All books</h1>
<ul className="mt-4 space-y-2">
{books.map((book) => (
<li key={book.id}>
<Link href={`/books/${book.id}`} className="underline">{book.title}</Link> by {book.author}
</li>
))}
</ul>
</section>
);
}
becomes a file out/books.html with the four books already written into the HTML. If a price changes later, the site does not know until you build and upload again.
Client Components work normally in the browser. Book Nook’s CartProvider and AddToCartButton keep working, because their state lives in the browser anyway. Links between pages still feel instant, because Next.js also writes small data files for client-side navigation.
Step 2: what does not work
Anything that needs a server at request time is not supported. The Next.js docs list these for the App Router:
- Dynamic routes with
dynamicParams: true, or dynamic routes withoutgenerateStaticParams(). - Route Handlers that read the
Request. cookies()(so no login sessions).rewrites,redirectsandheadersinnext.config.mjs.proxy.js.- Incremental Static Regeneration (pages that refresh themselves).
- Image optimization with the default loader.
- Draft Mode.
- Server Actions (so no review form, no admin price form).
- Intercepting routes.
In Book Nook terms: the catalogue pages, about page and cart can be exported. Search with searchParams, reviews, login and the admin area cannot. If you try to use one of these features while output: 'export' is on, next dev shows an error, so you find out early.
Step 3: the Cache Components problem
Here is something the docs do not spell out, which we found by building our course project. Book Nook was created with Cache Components on, and its next.config.mjs has cacheComponents: true and partialPrefetching: true. Add output: 'export' to that config and run npm run build. Pages are generated, and then the build stops:
✓ Generating static pages using 8 workers (11/11) in 9.2s
Finalizing page optimization ...
> Build error occurred
Error: Invariant: PPR cannot be enabled in export mode
Removing only partialPrefetching is not enough; the error stays. With Cache Components on, every page uses Partial Prerendering (PPR), which needs a server to stream in the dynamic parts, and an export has no server. In Next.js 16.4, a static export needs both flags off.
And once cacheComponents is off, 'use cache' is no longer allowed either:
Error: To use "use cache", please enable the feature flag `cacheComponents` in your Next.js config.
Read more: https://nextjs.org/docs/app/api-reference/directives/use-cache#usage
./lib/books.js:19:3
So the honest answer is: a static export is a different flavour of the project. The cleanest way to try it is a copy of Book Nook (or a new create-next-app project answering No to Cache Components) with:
- A
next.config.mjswithoutcacheComponentsandpartialPrefetching. - A
lib/books.jswithout'use cache',cacheLifeandcacheTag. Caching is pointless anyway: everything is read exactly once, at build time.
The data can still come from your Postgres database. next build runs on your computer (or in CI), where DATABASE_URL is set, and the results are frozen into HTML. Or use the simple array version of lib/books.js from Dynamic Routes and params:
// lib/books.js (static catalogue version: no 'use cache')
import 'server-only';
import { db } from './db';
export async function getBooks() {
return db.query('SELECT * FROM books ORDER BY title');
}
export async function getBook(id) {
const rows = await db.query('SELECT * FROM books WHERE id = $1', [id]);
return rows[0] ?? null;
}
Step 4: turn on the export
Here is the config for the static catalogue:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// GitHub Pages serves the site at /book-nook, not at /
basePath: '/book-nook',
// There is no server to resize images, so serve them as they are
images: { unoptimized: true },
turbopack: {
rules: {
"*.css": {
condition: { not: { path: "*.module.css" } },
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
Three new settings:
output: 'export'tellsnext buildto write a static site intoout/.basePathis a prefix for every URL. On GitHub Pages, a project site lives athttps://your-username.github.io/book-nook/, so all paths must start with/book-nook.<Link href="/books">becomes/book-nook/booksautomatically. You only need it when the site does not live at the root of a domain; leave it out forhttps://booknook.example.com.images: { unoptimized: true }turns off image optimization for every<Image>. The default image optimizer resizes images on request, and there is no server to do that. Do not skip it: without it the build still succeeds, but the images point at an optimizer that does not exist. Your images still getwidth,heightand lazy loading; they are just served as they are. (Another option is a customloaderthat points at an image service. See Optimizing Images with next/image.)
The book page needs generateStaticParams, so the build knows every id (see Pre-rendering Dynamic Routes with generateStaticParams):
// app/books/[id]/page.js
import { notFound } from 'next/navigation';
import { getBook, getBooks } from '@/lib/books';
export async function generateStaticParams() {
const books = await getBooks();
return books.map((book) => ({ id: book.id }));
}
export default async function BookPage({ params }) {
const { id } = await params;
const book = await getBook(id);
if (!book) notFound();
return (
<section>
<h1 className="text-3xl font-bold">{book.title}</h1>
<p className="text-zinc-600">by {book.author}</p>
<p className="mt-4">{book.description}</p>
</section>
);
}
A book that is added to the database after the build has no page until you build again. Visitors who type an unknown id get the 404.html page.
A static JSON file from a Route Handler
Route Handlers can be exported too, if they only answer GET and do not read the request. You must mark them static. This is allowed again, because Cache Components is off:
// app/books.json/route.js
import { getBooks } from '@/lib/books';
export const dynamic = 'force-static';
export async function GET() {
const books = await getBooks();
return Response.json(books);
}
The folder name books.json becomes the file name, so the export contains out/books.json. Without the dynamic line, the build stops with Error: export const dynamic = "force-static"/export const revalidate not configured on route "/books.json" with "output: export". (In a Cache Components project you would see the opposite error: Route segment config "dynamic" is not compatible with nextConfig.cacheComponents.)
Step 5: build and look inside
npm run build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /books
├ ○ /books.json
└ /books/[id]
├ ● /books/the-hobbit
├ ● /books/pride-and-prejudice
├ ● /books/dune
└ ● /books/the-little-prince
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
This is the route table of a project without Cache Components, so you see the ● (SSG) symbol for pages made from generateStaticParams. Every route is ○ or ●: nothing is left for a server. The out folder looks like this:
out/
├── index.html
├── about.html
├── books.html
├── books.json
├── books/
│ ├── dune.html
│ ├── pride-and-prejudice.html
│ ├── the-hobbit.html
│ └── the-little-prince.html
├── 404.html
├── _next/ JavaScript, CSS and fonts
└── ... public/ files and small .txt data files for navigation
Open out/index.html in an editor and you will see every link and script starts with /book-nook/, thanks to basePath.
To preview it, serve the folder with any static file server. Because of basePath, the files must appear under /book-nook, so the simplest check is to temporarily remove basePath, rebuild, and run:
npx serve out
Do not double-click index.html to open it from disk: the absolute paths like /_next/... only work through a web server.
Step 6: publish to GitHub Pages
GitHub Pages hosts static files for free from a repository. The modern way is a GitHub Actions workflow: a small YAML file that tells GitHub to build your site on every push and publish the out folder.
First, one extra file. GitHub Pages runs a tool called Jekyll by default, which ignores folders starting with _, like _next. An empty file named .nojekyll turns that off. Put it in public/, so it is copied into out:
# macOS and Linux
touch public/.nojekyll
On Windows, create an empty file named .nojekyll in the public folder from VS Code.
Then add the workflow:
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- uses: actions/upload-pages-artifact@v5
with:
path: out
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
What it does: on every push to main, it checks out your code, installs Node.js and your packages, runs next build (which writes out/), uploads out as the site, and publishes it. If your lib/books.js reads Postgres, add DATABASE_URL under your repository’s Settings, Secrets and variables, Actions. If it uses the array version, delete the env lines.
Finally, in the repository go to Settings, then Pages, and set Source to GitHub Actions. Push, wait for the green tick in the Actions tab, and your catalogue is live at https://your-username.github.io/book-nook/. The action versions shown were current when this was written; GitHub’s own Pages docs list the latest ones. The Next.js team also keeps a GitHub Pages template you can compare with.
Hosting on other servers
The out folder works on any host that serves files. On nginx, for example, you can make /books/dune find books/dune.html:
# nginx: part of a server block
root /var/www/book-nook/out;
location / {
try_files $uri $uri.html $uri/ =404;
}
error_page 404 /404.html;
If you prefer books/dune/index.html style folders (some hosts need that), add trailingSlash: true to next.config.mjs. Links then end in /, like /books/dune/.
Common mistakes
Leaving Cache Components on
Error: Invariant: PPR cannot be enabled in export mode
Remove cacheComponents and partialPrefetching from the config, and remove 'use cache', cacheLife and cacheTag from your code.
The site has no styles on GitHub Pages
The HTML loads but CSS and JavaScript return 404. Either basePath does not match the repository name (it is case-sensitive), or _next is being hidden by Jekyll because .nojekyll is missing.
Images are broken
Two different causes, and the build does not warn you about either:
- You forgot
images: { unoptimized: true }. In our test with Next.js 16.4, the export still built, but every<Image>of a JPG or PNG pointed at/_next/image?url=..., the address of the image optimizer. A static host has no optimizer, so those images 404. (SVG files are served as they are, so they hide the problem.) - The path is missing
basePath. Withunoptimized,<Image src="/covers/dune.jpg">is written exactly assrc="/covers/dune.jpg", without/book-nookin front. As thebasePathdocs say, add the prefix yourself:src="/book-nook/covers/dune.jpg". A small helper or an environment variable keeps that in one place.
Expecting new data without a rebuild
A static export is frozen at build time. After you change data, run the build again (with the workflow above, just push a commit, or click Re-run on the last workflow run).
Using a server feature by accident
A Server Action, cookies() or proxy.js makes next dev complain and the export fail. Move that feature to a server-hosted version of the app (Deploying to Vercel or Self-Hosting with Node.js and Docker), or replace it with client-side code.
Quick recap
output: 'export'makesnext buildwrite a plain static site toout/that any file host can serve.- Server Components run once at build time; Client Components work as usual in the browser.
- Server Actions, cookies,
proxy.js, rewrites, redirects, ISR and default image optimization are not available. - In Next.js 16.4, a static export needs
cacheComponentsandpartialPrefetchingturned off, and no'use cache'in the code. - Dynamic routes need
generateStaticParams; static Route Handlers needexport const dynamic = 'force-static'. - For GitHub Pages, set
basePathto the repository name, useimages: { unoptimized: true }, addpublic/.nojekyll, and deployoutwith a GitHub Actions workflow.
// next.config.mjs: a static export for GitHub Pages
// (Cache Components and Partial Prefetching must be OFF for output: 'export')
/** @type {import('next').NextConfig} */
const nextConfig = {
// Write plain HTML, CSS and JS into the "out" folder during next build
output: 'export',
// The site lives at https://your-username.github.io/book-nook/
basePath: '/book-nook',
// No server means no image optimizer: serve images as they are
images: { unoptimized: true },
// Optional: write books/dune/index.html instead of books/dune.html
// trailingSlash: true,
turbopack: {
rules: {
"*.css": {
condition: { not: { path: "*.module.css" } },
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
// --- next file: app/books.json/route.js (exported as out/books.json)
// import { getBooks } from '@/lib/books';
// export const dynamic = 'force-static';
// export async function GET() {
// return Response.json(await getBooks());
// }
// --- next file: app/books/[id]/page.js needs generateStaticParams
// export async function generateStaticParams() {
// const books = await getBooks();
// return books.map((book) => ({ id: book.id }));
// }
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready