Back to blog

Next.js App Router and static export: a mental model

If you ship services and CLIs but only touch browsers through DevTools, frontend apps can feel like a black box: same TypeScript, different rules. Here is a concise mental model for Next.js with the App Router, especially when you opt into a fully static export.

Stylized illustration of a React and Next.js dashboard with floating UI and code elements

Contents

Three places code can run

Most confusion comes from mixing these up:

  1. Build time: when you run next build, the toolchain reads your source (and often your content files) and writes static assets (HTML, JS, CSS) to disk.
  2. Server request time: on each visit, a server can run code and stream HTML. A pure static deployment does not use this for generating HTML on demand.
  3. Browser run time: the user’s tab downloads JS and runs it. That is where clicks, keyboard shortcuts, localStorage, and client-side navigation happen.

Backend engineers are used to (1) deploy scripts and (2) request handlers. Client-heavy apps add (3) as a first-class citizen. With static export, (1) does almost all the HTML generation; (3) handles interactivity.


What the App Router is doing

Next.js maps folders under app/ (or src/app/) to URLs. That is the App Router.

Example pathURL
app/page.tsx/
app/about/page.tsx/about
app/blog/page.tsx/blog
app/blog/[slug]/page.tsx/blog/some-post-slug

A layout.tsx wraps its segment and all child segments. The root layout wraps the whole app: shared chrome (header, footer), global providers, and the {children} slot where each page’s content goes. Think of it as nested templates, not a separate “SPA shell” you manually route inside; the file tree is the route table.

Dynamic segments like [slug] mean one module handles many URLs. If those URLs are pre-rendered at build time, the build must know the full list of slugs up front (usually via static path generation), and each one becomes its own exported HTML.


Static export: no app server

Setting output: “export” in Next config tells the framework: emit a directory of static files (commonly out/) that any static host (object storage with a CDN, many “Jamstack” hosts, or plain file hosting) can serve. There is no Node (or other app runtime) process tied to your project that must answer HTTP for HTML in that deployment style.

Implications:

  • Secrets and per-user logic cannot live only in shipped front-end code; you need a real backend or edge functions elsewhere if you need them.
  • Next features that assume a long-lived Node server for every request are either unavailable or narrowed for this mode; the build renders once, then ships files.

In that world, documentation about “React Server Components” still applies to what runs during the build, not to a pool of servers waiting behind a load balancer for your personal site.


Server components vs client components

In the App Router, components are Server Components by default. They can be async, use server-only modules, and read the filesystem during the build when you are producing static HTML.

Client Components are marked with "use client". They ship to the browser: they can use useState, useEffect, browser APIs, and event handlers.

A common split:

  • Server (default): layouts and pages that only render markup and load data at build time.
  • Client: pieces that need interactivity; search or command palettes, theme toggles, anything that uses useRouter for client navigation, localStorage, or window.

The boundary matters: you pass serializable props from server to client. Interactive components define their own event handlers on the client; you do not pass server functions as props across that line.

For example, a server component might fetch data at build time:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map(p => ({ slug: p.slug }))
}

export default async function Post({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

Then a client component handles interaction:

// components/ThemeToggle.tsx
"use client"
import { useState } from "react"

export default function ThemeToggle() {
  const [dark, setDark] = useState(false)
  return <button onClick={() => setDark(!dark)}>Toggle</button>
}

Typical content-driven pages

A frequent pattern is Markdown (or MDX) on disk, a small module that walks files and parses frontmatter, and helpers such as “list all posts” and “get one post by slug.”

For dynamic routes, generateStaticParams (or the equivalent in your setup) returns every { slug } (or similar) so the builder pre-renders each page. Post bodies then land in the static output; no client-side fetch is required for the article HTML in production if you designed it that way.

This is the key difference from a traditional SPA: data lives in the build output as HTML, not in API responses that JavaScript fetches at runtime. The HTML is already there, parsed and painted. Markdown is often rendered to React on the server/build side (for example with a Markdown-to-React library), so the pipeline stays content → data → HTML, not JavaScript → fetch API → render.


React’s role after the HTML exists

The first paint can be plain HTML. The browser then loads JS. Hydration is the crucial middle step: React looks at the HTML that already exists in the DOM, and attaches event listeners and state to make it interactive.

Think of it this way: the server rendered <button>Toggle Dark Mode</button> as plain HTML. The browser paints it immediately; the user sees it. But it does nothing when clicked. Then the browser runs your JavaScript bundle. React finds that button, realizes it’s connected to a Client Component with a click handler, and wires it up. Now it works.

This is why server and client must render the same shape. If the server rendered a button but the client JavaScript renders a link instead, React detects a mismatch and can’t hydrate correctly; it may reset the DOM or throw an error.

For a typical blog, most of the page (header, post content, footer) is server-rendered HTML with zero JavaScript. A small Client Component for the dark theme toggle hydrates separately. The result is fast first paint and minimal JS overhead.

Client-side navigation (Link, useRouter) can avoid full reloads while still loading pre-built route payloads—consistent with a static export.


Styling and theme

Utility-first CSS (Tailwind is common) plus CSS custom properties for colors is a typical combo. Define variables in your stylesheet, say --bg-primary and --text-primary, and let a Client Component toggle them:

// components/ThemeToggle.tsx
"use client"
export default function ThemeToggle() {
  const toggleTheme = () => {
    document.documentElement.classList.toggle("dark")
  }
  return <button onClick={toggleTheme}>🌙</button>
}

Your CSS reads the class:

:root {
  --bg-primary: white;
  --text-primary: black;
}
:root.dark {
  --bg-primary: #1a1a1a;
  --text-primary: white;
}

Anything that reads localStorage or system color scheme stays in Client Components—those APIs exist only in the browser. The server can't know the user's preference at build time, so the page renders with a default, then JavaScript adjusts it after hydration.


Bottom line: separate when work runs, mostly at build vs in the browser, and the App Router’s file-based routes plus the server/client split become easier to reason about than “all React runs in the tab.”