Skip to main content
Back to Research

Next.js App Router Production Patterns

Real production patterns for the App Router that official documentation glosses over: static export constraints, RSC decisions, bundle splitting, and…

Abstract

The Next.js App Router documentation covers the happy path well. This article covers what happens after you ship: static export gotchas, the RSC vs. client component decision tree, aggressive lazy loading patterns, font optimization, and the hydration mismatches that silently corrupt your SSR output.

April 20, 2026
8 min read

The Static Export Reality

If you're running output: 'export', you are building a completely different application than the App Router documentation describes. Most App Router features — server actions, route handlers, middleware, streaming, partial prerendering — do not exist in a static export. You're building a static site generator that happens to use React 19 and Turbopack.

The documentation doesn't make this sufficiently clear. You discover the constraints when your build fails.

Rules that apply when output: 'export' is set:

  • Every dynamic route segment needs generateStaticParams(). Missing it means the route doesn't exist in the output.
  • headers(), cookies(), and redirect() cannot be called from Server Components. They depend on the server request context.
  • No API routes. The /api directory simply doesn't get output to the dist/ folder.
  • The dynamic = 'force-dynamic' export on any route breaks the build. Use 'force-static' for routes that need explicit control (like manifest.ts).

If you're not on static export, most of these constraints don't apply. But if you are — and many portfolio, marketing, and documentation sites should be — internalize them before writing any route.

RSC vs. Client Components — The Actual Decision Tree

The official heuristic is "server by default, client when needed." This is correct but incomplete. Here's the decision tree I use:

Start with a Server Component (or static rendering) unless any of the following are true:

  • The component uses browser-only APIs (window, document, localStorage, navigator)
  • The component needs React hooks (useState, useEffect, useRef, custom hooks)
  • The component receives interactive event handlers (onClick, onChange, etc.)
  • The component needs to access the React Context API as a consumer
  • The component depends on third-party libraries that haven't been written for RSC (most animation libraries, chart libraries, drag-and-drop)

When you add 'use client', you're creating a client boundary. Everything below that boundary is also client-rendered. This means that large UI trees should have client boundaries pushed as deep as possible — interactive islands surrounded by static shells, not the other way around.

A common mistake: wrapping an entire page layout in a context provider (for theming, i18n, or auth) forces the entire page tree client-side. The right pattern is a thin client provider that only provides context, with children rendered as RSC where possible. In practice, with App Router this means wrapping the provider in a 'use client' component but keeping children typed as React.ReactNode so RSC children can be passed through without forcing them client-side.

Lazy Loading That Actually Works

React's lazy() + Suspense is the correct primitive for code splitting in App Router. Do not use dynamic import without a Suspense boundary — the component will flash or cause a hydration mismatch.

import { lazy, Suspense } from 'react';

const HeavySection = lazy(() => import('./HeavySection'));
const ThreeDScene = lazy(() => import('./ThreeDScene'));

export default function Page() {
  return (
    <>
      <HeroSection /> {/* loads synchronously */}
      <Suspense fallback={<SectionSkeleton />}>
        <HeavySection />
      </Suspense>
      <Suspense fallback={null}>
        <ThreeDScene />
      </Suspense>
    </>
  );
}

The fallback for 3D scenes is null, not a skeleton. Three.js takes significant time to initialize and the loading artifact looks worse than a blank space. For content sections, a skeleton that matches the section's layout height prevents cumulative layout shift.

Framer Motion deserves special treatment. Import LazyMotion and load features dynamically to avoid shipping the full Framer Motion bundle synchronously:

import { LazyMotion, domAnimation, m } from 'framer-motion';

// Use m.div instead of motion.div
// Load domAnimation (or domMax for layout animations) dynamically
const loadFeatures = () => import('framer-motion').then(res => res.domAnimation);

function AnimatedCard() {
  return (
    <LazyMotion features={loadFeatures} strict>
      <m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
        Content
      </m.div>
    </LazyMotion>
  );
}

The strict prop on LazyMotion throws an error if you accidentally import motion (the full bundle) anywhere in the tree. Use it.

Font Optimization

next/font/google is the only correct way to load Google Fonts in a Next.js application. Do not use a <link> tag to fonts.googleapis.com. The font loader handles subsetting, self-hosting the font files, and setting font-display: swap automatically.

import { Inter, JetBrains_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const mono = JetBrains_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-mono',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html className={`${inter.variable} ${mono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Add <link rel="preconnect"> tags for fonts.googleapis.com and fonts.gstatic.com in the root layout. The font loader doesn't add these automatically and the connection establishment time shows up in Lighthouse.

Hydration Pitfalls

Hydration mismatches are the most confusing class of Next.js bugs because the error message rarely tells you where the mismatch is. The common causes:

Date/time rendering. new Date().toLocaleDateString() produces different output on the server (UTC) and the client (user's timezone). Either render dates statically at build time or use suppressHydrationWarning on the element and accept that the first render may flash.

Math.random() in default state. Any ID, key, or value generated with Math.random() during render will be different on server and client. Use stable IDs derived from data. If you need a random ID for an accessible label, generate it with useId() (React 18+) which produces deterministic IDs per render position.

localStorage reads during render. localStorage does not exist on the server. Any component that reads from it during the initial render will throw or produce empty values server-side. Read from localStorage only inside useEffect, never in the render path.

Browser extension interference. Extensions that inject attributes into the DOM (password managers, accessibility tools) cause hydration warnings that are not your bug. The suppressHydrationWarning prop on the <body> or <html> element suppresses these without masking real issues.

generateStaticParams Patterns

For a bilingual site, the minimal pattern is:

export function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'es' }];
}

For content-driven routes, fetch the content list and return the union of locales × slugs:

export async function generateStaticParams() {
  const articles = await getArticles(); // reads from src/content/articles/
  const locales = ['en', 'es'];
  return locales.flatMap(locale =>
    articles.map(article => ({ locale, slug: article.slug }))
  );
}

This runs at build time only. No network requests, no database calls — read from the filesystem. If you're using output: 'export', this is the only correct pattern.

Bundle Splitting Reality

Next.js handles route-based code splitting automatically. What it does not handle is library splitting within a route. If you import Three.js, Framer Motion, and GSAP in the same route without lazy loading, they all land in the same chunk.

The bundle analyzer (ANALYZE=true npm run build with @next/bundle-analyzer) is the only way to see the actual chunk breakdown. Run it before optimizing — you'll often find that your intuition about what's large is wrong. React DOM and Next.js internal polyfills are usually larger than expected; specific feature libraries are sometimes smaller.

One pattern worth knowing: if a library is only used on one route, Next.js will often split it automatically. If it's imported across multiple routes (even via a shared component), it gets hoisted to a shared chunk. Be intentional about which components import which libraries.

Root Layout vs. Locale Layout

With a [locale] route segment, you need two layout levels: the root layout (src/app/layout.tsx) which renders <html> and <body>, and the locale layout (src/app/[locale]/layout.tsx) which provides i18n context and locale-specific metadata. The locale layout must not render <html> or <body> — that's the root layout's responsibility.

Without a root layout, Turbopack in development will often work but the production build may fail. Both layouts are always required when you have route segments outside the catch-all.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP