NorthLuma blog · · 7 min read

Shipping a client site with Next.js static export, end to end

Set up Next.js 16 static export for a client site: config, sitemap and robots, service pages with generateStaticParams, checking out/, and a handoff list.

A brochure site for a plumber or a salon rarely needs a server. Next.js can build the whole thing into plain HTML, CSS and JavaScript in an out/ folder, which you can put on any static host and hand over without a Node.js process to babysit. The static exports guide describes it as a mode that "can be deployed and hosted on any web server that can serve HTML/CSS/JS static assets."

This is the setup we use for a small service-business site on Next.js 16.4.1, with every file below built in a scratch project before it went into this post. The two build errors quoted further down are the real messages we got.

Create the project

The installation page gives the quick start:

npx create-next-app@latest my-app --yes
cd my-app

--yes accepts the defaults: TypeScript, Tailwind CSS, ESLint, App Router, Turbopack and the @/* import alias. Node.js 20.9 is the documented minimum.

One default matters for static export. The create-next-app reference lists --cache-components as the "default for App Router", and the generated next.config.ts we got contained cacheComponents: true and partialPrefetching: true. Cache Components is built around a prerendered shell plus content that streams in at request time, and a static host has no request time. In our test, that combination with output: 'export' made app/robots.ts impossible to build: with dynamic = 'force-static' the build failed with "Route segment config "dynamic" is not compatible with nextConfig.cacheComponents", and without it the export failed because the route was not marked static. The dynamicParams docs also say "dynamicParams is not available when Cache Components is enabled."

Our recommendation for a brochure site: delete those two lines and keep the config below.

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
};

export default nextConfig;

output: "export" is the switch. After next build, the guide says, "Next.js will create an out folder with the HTML/CSS/JS assets for your application."

trailingSlash: true changes the file layout. Per the trailingSlash docs, with static export "the /about page will output /about/index.html (instead of the default /about.html)." Folder-plus-index files work on nearly every host without rewrite rules, which is why we turn it on.

images.unoptimized is the one people forget. Image optimization with the default loader is on the unsupported list, and the Image docs say that with unoptimized set to true, "The source image will be served as-is from the src instead of changing quality, size, or format." We removed the line to see what happens: the build still succeeded, and the img tags in out/index.html pointed at /_next/image/?url=%2Fhero.png&w=3840&q=75. Nothing exists at that path in out/, so every image would break once deployed. A green build is not proof here; look at the HTML.

What static export cannot do

The guide's unsupported features list reads:

  • Dynamic Routes with dynamicParams: true
  • Dynamic Routes without generateStaticParams()
  • Route Handlers that rely on Request
  • Cookies
  • Rewrites
  • Redirects
  • Headers
  • Proxy
  • Incremental Static Regeneration
  • Image Optimization with the default loader
  • Draft Mode
  • Server Actions
  • Intercepting Routes

Proxy is the Next.js 16 name for middleware. The version 16 upgrade guide says "The middleware filename is deprecated, and has been renamed to proxy". When we added a proxy.ts to the test project, the build did not fail. It printed a Proxy line and produced out/ anyway, but a static host never runs that code. Redirects and security headers move to the host's own config, which the deployment post covers.

Server Actions being gone means a contact form needs a third-party form endpoint or a mailto link.

Content in one file, metadata in the layout

Keep the business data out of the markup so the client's next developer edits one file.

// lib/services.ts
export type Service = {
  slug: string;
  name: string;
  summary: string;
};

export const services: Service[] = [
  {
    slug: "emergency-repairs",
    name: "Emergency repairs",
    summary: "Burst pipes, leaks and blocked drains, 24 hours a day.",
  },
  {
    slug: "boiler-installation",
    name: "Boiler installation",
    summary: "New boilers fitted and registered, old units removed.",
  },
  {
    slug: "bathroom-renovation",
    name: "Bathroom renovation",
    summary: "Full refits from first fix to tiling.",
  },
];

export function getService(slug: string) {
  return services.find((s) => s.slug === slug);
}

In the root layout, metadataBase lets child pages use relative URLs, and a title template keeps page titles consistent. The metadata docs note that "title.default is required when you add a title.template."

// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  metadataBase: new URL("https://www.example-plumbing.com"),
  title: {
    template: "%s | Example Plumbing",
    default: "Example Plumbing, Uppsala",
  },
  description: "Emergency plumbing, boilers and bathrooms in Uppsala.",
};

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

One page per service with generateStaticParams

generateStaticParams tells the build which slugs exist. Setting dynamicParams to false means unknown slugs "will return a 404", which matches what a static host does anyway.

// app/services/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getService, services } from "@/lib/services";

export const dynamicParams = false;

export function generateStaticParams() {
  return services.map((s) => ({ slug: s.slug }));
}

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const service = getService(slug);
  return {
    title: service?.name,
    description: service?.summary,
    alternates: { canonical: `/services/${slug}/` },
  };
}

export default async function ServicePage({ params }: Props) {
  const { slug } = await params;
  const service = getService(slug);
  if (!service) notFound();

  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="text-3xl font-bold">{service.name}</h1>
      <p className="mt-4">{service.summary}</p>
    </main>
  );
}

Delete generateStaticParams and the build stops with: Page "/services/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.

sitemap.ts and robots.ts

The sitemap and robots docs both say the file is "a special Route Handler that is cached by default unless it uses a Request-time API or dynamic config option", which reads as if no extra config is needed. With static export it is needed. Without export const dynamic = "force-static" our build failed with export const dynamic = "force-static"/export const revalidate not configured on route "/sitemap.xml" with "output: export". The static exports guide says the same thing for Route Handlers in general.

// app/sitemap.ts
import type { MetadataRoute } from "next";
import { services } from "@/lib/services";

export const dynamic = "force-static";

const base = "https://www.example-plumbing.com";

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: `${base}/` },
    ...services.map((s) => ({ url: `${base}/services/${s.slug}/` })),
  ];
}
// app/robots.ts
import type { MetadataRoute } from "next";

export const dynamic = "force-static";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: "https://www.example-plumbing.com/sitemap.xml",
  };
}

We left out lastModified: new Date() from the docs example on purpose. On a static build it stamps every URL with the build time, not the date the page last changed.

Build and check out/

npx next build
find out -type f -not -path "out/_next/*" | sort

Our build listed the three service pages as SSG routes, and out/ held:

out/404.html
out/404/index.html
out/index.html
out/robots.txt
out/services/bathroom-renovation/index.html
out/services/boiler-installation/index.html
out/services/emergency-repairs/index.html
out/sitemap.xml

There are also .txt payload files next to each page and the hashed assets in out/_next/static/. Open a service page's HTML and confirm the title reads "Boiler installation | Example Plumbing" and the canonical link is absolute.

To click through it like a visitor would:

npx serve out

Before you hand it over

  • cacheComponents and partialPrefetching removed from next.config.ts, output: "export" present.
  • No img in out/ pointing at /_next/image.
  • Every service slug has a folder in out/services/.
  • out/sitemap.xml uses the production domain, with trailing slashes matching your trailingSlash setting.
  • out/robots.txt does not block the whole site (a leftover staging rule is easy to miss).
  • No proxy.ts or middleware.ts the client expects to do something; redirects live in the host config instead.
  • The contact form posts somewhere that works without a server.
  • A README says: run npm run build, upload out/, edit content in lib/services.ts.

If you would rather start from a generated site than a blank scaffold, NorthLuma's builder says generating and previewing is free with no signup. The pricing page has the current price and availability of Pro Source. The docs describe its Next.js project as keeping the site's text in lib/site-content.ts, and the licence lets you resell the site as part of an agency-built site for your own clients.


Sources

NorthLuma team. Facts and sources last checked Oct 16, 2026; next review due by Jan 25, 2027. Found something out of date? Email [email protected].