On this page

Chapter 2 of 8. Prerequisite: Chapter 1. You've decided static export fits and you know what it forbids. This chapter is the config that turns that decision into an out/ folder cPanel can serve, ready for the GitHub Actions FTP job to ship.

The three settings that matter

Exactly three keys in next.config.ts make a Next.js site export to static files that a cPanel host will serve correctly. Here is the whole config object from this site (captainrandom.co.uk, in production) with nothing paraphrased:

const nextConfig: NextConfig = {
  output: 'export',
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
  images: {
    unoptimized: true,   // Required for static export — Next image optimisation needs a server
  },
  trailingSlash: true,   // clean URLs on cPanel static hosting (/writing/ not /writing)
}

Note what is not here. There's no basePath, no assetPrefix, and no distDir. The site lives at the domain root and exports to the default out/ folder, so none of those apply. That leaves three load-bearing keys plus pageExtensions, which feeds the MDX pipeline that Chapter 3 covers.

The rest of this chapter takes each key in turn: what it does, what breaks without it, and why the cPanel target specifically forces the value.

output: 'export' and the out folder that becomes public_html

This is the switch that changes everything downstream. Set output: 'export' and next build stops producing a .next/ server bundle. Instead it writes an out/ folder of plain HTML, CSS, and JS. Per the Next.js static-export docs, "Next.js will create an out folder with the HTML/CSS/JS assets for your application," and "it can be deployed and hosted on any web server that can serve HTML/CSS/JS static assets."

That last sentence is the entire cPanel contract. A cPanel shared host serves whatever files sit in public_html/, so the deploy reduces to three moves: build locally or in CI, take the contents of out/, and drop them into public_html/. No Node process runs on the host, and there's no npm start. The host is a plain file server, and static export hands it exactly the files it knows how to serve.

What you give up is the list the docs enumerate: Dynamic Routes without generateStaticParams(), Route Handlers that read the request, Rewrites, Redirects, Headers, Incremental Static Regeneration, Server Actions, Draft Mode, and Image Optimization with the default loader. Chapter 1 covered why that trade is fine for a content site. The point to internalise here is that setting this one key is what enforces the trade. Several of the forbidden features fail next build outright, and the ones that don't (such as reading dynamic request values in a Route Handler) simply don't work once deployed. Either way you're steered off them early rather than mid-launch.

Route Handlers deserve a specific mention because they look like they might survive, and partly they do. The docs say a Route Handler renders a static response at build time and "Only the GET HTTP verb is supported." A GET handler that returns a fixed JSON blob at build therefore works fine. Anything that reads cookies, headers, or the request body does not, and "If you need to read dynamic values from the incoming request, you cannot use a static export" is the exact wording. This site has no Route Handlers at all: the newsletter endpoint is a separate PHP backend on the same cPanel, precisely because a static export cannot host a POST endpoint.

trailingSlash: true, and why a refresh on /about breaks without it

This is the key that fixes the single most common cPanel symptom. You deploy, the homepage works, you navigate to /about and it works, then you hit refresh on /about and get a 404 or, worse, the homepage. The cause is a mismatch between how Next names the exported files and how a file server resolves URLs.

By default, a static export writes the page at route /about to the file out/about.html. That's fine while Next's client router is driving navigation. A hard refresh, though, asks the server for /about, and a plain file server looking for /about finds a directory-shaped path with no index.html inside it. It has no reason to try about.html instead, so the request 404s.

Set trailingSlash: true and the export changes shape. The docs describe it exactly: the optional trailingSlash: true config changes links from /me to /me/ and emits /me.html as /me/index.html. So /about now exports to out/about/index.html. When the server gets a request for /about/, it looks in the about/ directory, finds index.html, and serves it. Both a refresh and a deep link pasted straight into the address bar resolve, because that directory-index lookup is behaviour a file server already knows how to do. That's why the setting exists.

I use /writing/ rather than /writing across this whole site for that reason, and the inline comment in the config says as much. Once you commit to trailingSlash: true, keep every internal link consistent with it. next/link handles the slash for you, but any hand-written href or sitemap entry should carry the trailing slash so nothing triggers an extra redirect hop.

images.unoptimized: true, because the export has no server to optimise on

Next's <Image> component normally routes every image through an on-demand optimisation endpoint that resizes, re-encodes to modern formats, and serves the right variant per device. That endpoint is a running server, and a static export has no server behind it. The default image loader therefore can't work under output: 'export', which is why the docs list Image Optimization with the default loader among the unsupported features.

The fix is one key: images.unoptimized: true. It tells Next to skip the optimisation pipeline entirely and emit <img> tags that point straight at the source files copied into out/, so the images ship as-is. You lose automatic resizing and format conversion. In return you keep a build that completes and a site that serves images from a plain file host.

The docs note a second path: a custom image loader (loader: 'custom' with a loaderFile) that hands optimisation off to a service like Cloudinary. That earns its keep if you have a lot of large imagery and a CDN budget behind it. This site pushes its hero artwork through a build-time convention instead of next/image, so unoptimized: true is the right call and a loader would buy nothing.

pageExtensions, so that .mdx files can be pages

The fourth key isn't a static-export setting at all; it's the hinge into the MDX pipeline. By default Next treats .js, .jsx, .ts, and .tsx as page files. Add md and mdx to pageExtensions and Next will also treat Markdown and MDX files as routable pages and importable modules:

pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],

The @next/mdx readme documents this exact list, since treating MDX files as pages requires registering the extensions here. Leave it out and @next/mdx will still compile .mdx content, but Next never picks those files up as routes. On this site the content isn't file-routed one page per MDX file; a build-time loader reads the MDX out of src/content/posts/. Even so, the extension registration is what lets MDX participate in the module graph. Chapter 3 wires the full pipeline.

Why the file is next.config.ts, not next.config.js

One detail trips people up: this config imports ESM-only plugins. The @next/mdx wrapper pulls in remark-gfm and rehype plugins, and the Next docs are explicit that "because the remark/rehype ecosystem is ESM only you must use next.config.mjs or next.config.ts as the config file." A plain CommonJS next.config.js cannot import them. This site uses .ts, which gives ESM imports plus type-checking of the config object against NextConfig. Copy a .js config from an older tutorial, start adding MDX plugins, and you'll hit an import error whose real cause is the file extension rather than static export.

Verifying the export

With the config in place, the build script is stock:

npm run build     # next build → writes ./out/

After it runs, inspect out/. For every route you want to see a directory with an index.html inside it: out/index.html for the homepage, then out/writing/index.html, out/about/index.html, and so on. If you see bare out/about.html files instead, trailingSlash: true didn't take. If the build errors on an image or a dynamic route, one of the forbidden features slipped in; read the error, because it names the offending route.

The acceptance threshold for this chapter:

  • next build completes with output: 'export' set and writes an out/ folder.
  • Every route in out/ is a <route>/index.html, not a <route>.html.
  • No .next/server artefacts are needed to serve the site; out/ is fully self-contained.
  • The contents of out/ can be copied to public_html/ and served with no Node process running.

If all four hold, you have a folder cPanel can serve. The remaining chapters make it serve correctly. Chapter 3 builds the MDX pipeline on top of the pageExtensions you just set, and later chapters handle the .htaccess routing, the 404.html export bug, and the GitHub Actions FTP deploy that ships out/ to the host without wiping your hidden files.

// EXERCISE

Make the export shape a CI gate

Apply the static-export keys to a project of your own, then go beyond eyeballing out/ by writing a small verify script, wired as npm run verify:export, that fails the build whenever the exported shape breaks the contract. The chapter checks the folder by hand once; your script checks it on every build forever.

Expected behaviour
  • Your next.config.ts sets output: 'export', trailingSlash: true, and images.unoptimized: true, and next build writes an out/ folder
  • The script exits non-zero and names the offending file when any route exports as a bare <route>.html instead of <route>/index.html
  • The script exits non-zero when out/index.html is missing from the export root
  • A clean build passes the script with exit code 0, and the script runs automatically after the build step

PROVE IT Set trailingSlash to false, rebuild, and capture the script failing while naming a bare .html file, then restore the config and capture the green run.

// CHECKPOINT — EXPORT CONFIG
multiple choice · auto-checked

You deploy a default export with no trailingSlash. Client-side navigation to /about works, but a hard refresh on /about returns 404. Why?

exact answer · auto-checked

With trailingSlash: true set, which file does the route /about export to?

open · self-checked

You copy a next.config.js from an older tutorial, add remark-gfm, and the build fails on the import. What is the real cause and the fix?

Show answer

The remark and rehype ecosystem is ESM only, so a CommonJS next.config.js cannot import those plugins; the error points at the import but the real cause is the file extension. The docs require next.config.mjs or next.config.ts instead. Using .ts also gives you type-checking of the config object against NextConfig.

↺ re-read: “Why the file is next.config.ts, not next.config.js

Lived experience

Sources

  • How to create a static export of your Next.js application
    Next.js (Vercel)
    Backs the output:'export' contract, the out/ artefact that deploys to any static host, trailingSlash behaviour, and the list of features static export forbids (including Image Optimization with the default loader).
    nextjs.org
  • How to use markdown and MDX in Next.js
    Next.js (Vercel)
    Backs pageExtensions including md/mdx so MDX files are treated as pages, and the ESM-only config-file requirement that makes next.config.ts (not .js) the right place for this config.
    nextjs.org
  • next.js/packages/next-mdx/readme.md at canary · vercel/next.js
    vercel/next.js (GitHub)
    The @next/mdx package readme in the official source tree — backs pageExtensions listing md/mdx and the withMDX wrapper this config exports.
    github.com
Back to guide overview