Type something to search...
Next.js Creating a static export

Next.js Creating a static export

Sometimes the right amount of server infrastructure for a project is none at all. A marketing site, a documentation portal, a project that needs to run on GitHub Pages or an internal file server with no Node.js runtime available — these are all real, common cases where "deploy a Next.js server" is the wrong shape of solution entirely. Static export is Next.js's answer: next build produces plain HTML, CSS, and JS files that any static file host can serve, no server process required at all.

This article covers what a static export actually supports (more than you might expect, including a real subset of Server Components), what it explicitly doesn't (Server Actions, cookies, ISR — anything genuinely requiring a live server), and how to actually deploy the result.

Turning it on

One config flag is the entire opt-in:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",

  // Optional: /me -> /me/, emitting /me.html -> /me/index.html
  // trailingSlash: true,

  // Optional: preserve href exactly as written, skip the /me -> /me/ redirect
  // skipTrailingSlashRedirect: true,

  // Optional: rename the output directory from `out` to something else
  // distDir: 'dist',
};

module.exports = nextConfig;

Run next build, and Next.js produces an out/ directory (or whatever distDir renames it to) containing every static asset your app needs — nothing more to configure beyond this one flag for the common case.

What actually still works

This is worth taking seriously, because "static export" sounds like it should mean "no App Router features at all," and that's not accurate — a genuinely useful subset works exactly as you'd expect.

Server Components run at build time, not disappear

Server Components inside app still execute — just entirely during next build, in a manner functionally identical to traditional static-site generation. The output becomes static HTML for the initial load plus a static payload for client-side navigation between routes, with no code changes required to a Server Component to make it compatible with a static export, as long as it doesn't reach for something inherently dynamic (covered below).

// app/page.tsx
export default async function Page() {
  // This fetch runs on the server, during `next build` — not at request time
  const res = await fetch("https://api.example.com/...");
  const data = await res.json();
  return <main>...</main>;
}

The mental model shift worth internalizing: "at request time" becomes "at build time" for everything that would otherwise be server-rendered — the data is exactly as fresh as your last build, and no fresher, since there's no live server available to re-run this fetch later.

Client Components fetch normally, client-side

For anything that genuinely needs to fetch after the page has already loaded, a Client Component with a library like SWR works precisely as it would in any client-rendered app:

// app/other/page.tsx
"use client";

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export default function Page() {
  const { data, error } = useSWR(
    "https://jsonplaceholder.typicode.com/posts/1",
    fetcher,
  );
  if (error) return "Failed to load";
  if (!data) return "Loading...";
  return data.title;
}

Since route transitions between pages like this happen entirely client-side regardless of the static export, this behaves exactly like a conventional SPA — <Link> navigation between statically-exported pages continues to feel instant, with prefetching intact, even though every one of those pages was originally generated as a plain HTML file at build time.

Image Optimization needs a custom loader

next/image's default optimization path assumes a running server to handle resize/format requests at request time — which a static export, by definition, doesn't have. The fix is a custom loader pointing at an external image service instead:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
  images: {
    loader: "custom",
    loaderFile: "./my-loader.ts",
  },
};

module.exports = nextConfig;
// my-loader.ts
export default function cloudinaryLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  const params = ["f_auto", "c_limit", `w_${width}`, `q_${quality || "auto"}`];
  return `https://res.cloudinary.com/demo/image/upload/${params.join(",")}${src}`;
}

With this in place, next/image continues to work exactly as written in your components — <Image src="/turtles.jpg" ... /> — the difference is entirely in where the actual resizing/optimization happens: an external service like Cloudinary, resolved through your loader function, rather than Next.js's own built-in optimization endpoint that a static export has no server available to run.

Route Handlers can render to static files

A GET-only Route Handler renders to a static output file during next build, provided you explicitly mark it as static — this isn't automatic the way it is for pages, because a Route Handler could plausibly be dynamic and Next.js needs an unambiguous signal either way:

// app/data.json/route.ts
export const dynamic = "force-static";

export async function GET() {
  return Response.json({ name: "Lee" });
}

This produces a genuine static data.json file at build time, containing exactly { name: 'Lee' } — a legitimate way to generate static JSON, TXT, or other non-HTML output files as part of an otherwise ordinary Next.js build, without needing a separate build script outside the framework to produce them.

Browser APIs still need the usual guard

Client Components are prerendered to HTML during the build, which means window, localStorage, navigator, and similar browser-only globals genuinely don't exist yet at the point that prerender runs — this is exactly the same constraint that applies to ordinary SSR, not something specific to static export, and the fix is identical: access these APIs only inside a useEffect (or an equivalent client-only guard), never directly during render.

"use client";

import { useEffect } from "react";

export default function ClientComponent() {
  useEffect(() => {
    // window is genuinely available here — this runs client-side, post-mount
    console.log(window.innerHeight);
  }, []);

  return; /* ... */
}

What genuinely doesn't work, and why

This list matters more than it might first appear, because every item on it shares one underlying reason: each requires either a live Node.js server process or genuinely per-request dynamic computation that can't be resolved once, at build time, and then reused forever after.

  • Dynamic Routes with dynamicParams: true — the whole point of that flag is serving params not known at build time, which a static export inherently cannot do.
  • Dynamic Routes without generateStaticParams() — with no static params generated at all, there's nothing for the build to actually prerender.
  • Route Handlers that read from the live Request object — anything reading per-request specifics can't be resolved once and cached as a static file.
  • Cookies, Rewrites, Redirects, Headers, Proxy — every one of these is fundamentally a per-request server-side decision-making mechanism, with nowhere to actually run once there's no server process.
  • Incremental Static Regeneration — ISR's entire premise is revalidating content after the build completes, which requires a live server to actually receive and act on that revalidation trigger.
  • Image Optimization with the default loader specifically — the custom-loader path above is what makes images work at all under a static export; the default, server-backed path simply has no server to run against.
  • Draft Mode — inherently a live, server-side toggle for previewing unpublished content; there's no server process present to hold that toggle's state.
  • Server Actions — mutations fundamentally need a server to actually receive and execute them; under a static export there's nothing there to receive the POST at all.
  • Intercepting Routes — this routing pattern depends on server-side request handling to intercept and serve a different route than the URL would otherwise imply.

Attempting to use any of these while output: 'export' is set produces an error during next dev itself — the same behavior you'd get from explicitly setting dynamic = 'error' in a root layout, which is a deliberate, helpful signal, not an accidental side effect: the framework is telling you, as early as possible, that a specific feature genuinely cannot work in this deployment mode, rather than letting you discover it only once you try to deploy.

Deploying the actual output

Since a static export is just files, deployment is close to universal — anything that serves static HTML/CSS/JS works. Given routes like / and /blog/[id], next build produces exactly:

/out/index.html
/out/404.html
/out/blog/post-1.html
/out/blog/post-2.html

For a static host like Nginx that needs explicit rewrite rules to map incoming request paths to the correct generated files:

server {
  listen 80;
  server_name acme.com;
  root /var/www/out;

  location / {
      try_files $uri $uri.html $uri/ =404;
  }

  # Necessary specifically when trailingSlash: false — omit if trailingSlash: true
  location /blog/ {
      rewrite ^/blog/(.*)$ /blog/$1.html break;
  }

  error_page 404 /404.html;
  location = /404.html {
      internal;
  }
}

Worth flagging as an easy-to-miss detail: whether you need that /blog/ rewrite rule at all depends entirely on your trailingSlash setting — get this wrong relative to your actual config, and requests to nested routes 404 in production despite the exact same files having built and existing correctly locally.

For GitHub Pages specifically, an official template exists as a working reference — genuinely worth starting from rather than hand-configuring GitHub Pages' routing quirks yourself from scratch.

A brief but relevant history

Worth knowing if you're reading older tutorials or Stack Overflow answers that reference a next export command rather than the output: 'export' config option used throughout this article — that command genuinely existed once, and was fully removed as of v14.0.0. Version 13.3.0 deprecated it in favor of the config option covered here, and 13.4.0 is specifically when App Router static export support matured to include React Server Components and Route Handlers, rather than being limited to what the older Pages-Router-era next export command supported. If you find a tutorial referencing the standalone next export command, it's describing a mechanism that predates this current approach and no longer exists in the framework at all.

Key Takeaways

FeatureStatic export support
Server ComponentsYes — run once, at build time
Client Components + client-side fetchingYes — works exactly like an ordinary SPA
next/imageYes, but requires a custom loader pointing at an external service
Route HandlersYes, GET-only, and only with dynamic = 'force-static' explicitly set
Server Actions, cookies, ISR, Draft Mode, ProxyNo — all require a live server process
Deployment targetAny static file host — Nginx, GitHub Pages, S3, a CDN, anywhere

Static export is best understood as Next.js meeting you at "I genuinely don't need a server for this" without forcing you to give up the App Router's actual development model to get there — Server Components, route-based code-splitting, and next/image (with one extra config step) all still work, computed once at build time instead of per request. The trade-off is precise and mechanical, not vague: anything that fundamentally needs a live request to resolve — a cookie read, a Server Action, an on-demand revalidation — simply isn't available, and the framework tells you exactly that, as early as next dev, rather than leaving you to discover it only after a failed deployment.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper