Skip to main content
Zurück zum Blog
Next.jsPerformanceCode SplittingTurbopackReact Server Components

next/dynamic wird dich nicht retten: Warum CMS-gesteuerte Next.js-Seiten jeden Chunk ausliefern

Unsere CMS-gesteuerten Next.js-Seiten lieferten auf jeder Route alle ~100 Section-Komponenten aus – trotz lehrbuchmäßigem Einsatz von next/dynamic. Wir haben jede Lösung auf Bundler-Ebene ausgereizt (direktes import(), Datei-Splitting, Turbopack-Konfiguration, webpack splitChunks), bevor wir die wahre Ursache fanden: Erreichbarkeit, nicht Chunking. Codegen zur Build-Zeit plus rewrites senkten das First-Load-JS um 53%.

Veröffentlicht 18. Juli 202614 Min. Lesezeit

Wir hatten eine produktive Next.js-16-Site mit rund 700 CMS-gesteuerten Landingpages. Jede Seite wird aus „Sections" zusammengesetzt – Hero, FAQ, Reviews, Pricing und so weiter – etwa 100 React-Komponenten in 38 Section-Typen. Eine typische Seite rendert 10–15 davon. Die Startseite lieferte 5.3 MB First-Load-JavaScript aus. Als wir nachgruben, wohin das ging, fanden wir einen einzigen Chunk von 1.2 MB mit dem Code von 108 Sections – auf einer Seite, die 15 rendert.

Jede Section war bereits in next/dynamic eingewickelt. Das Code-Splitting sah lehrbuchmäßig korrekt aus. Und trotzdem lieferte der Bundler alles überall aus. Wir verbrachten Tage damit, jeden dokumentierten Hebel durchzuprobieren – dynamische Importe, direktes import(), Umstrukturierung von Dateien, Bundler-Konfiguration, sogar den Wechsel des Bundlers – und jeder einzelne scheiterte aus demselben nicht offensichtlichen Grund. Dieser Artikel geht jede Sackgasse mit echten Zahlen durch, erklärt die eigentliche Ursache und zeigt die Lösung, die 53% des First-Load-JS einsparte, ohne eine einzige Section-Komponente anzufassen.

TL;DR: Bei CMS-gesteuerten dynamischen Routen ist Code-Splitting kein Chunking-Problem, sondern ein Erreichbarkeitsproblem. Kein Bundler-Flag behebt das. Behoben wird es, indem man das Wissen darüber, „welche Komponenten diese Seite verwendet", von der Laufzeit in die Build-Zeit verlagert.

Die Architektur (die du wahrscheinlich auch hast)

Der Aufbau ist der Standard für jeden Page-Builder: Das CMS speichert eine Seite als geordnete Liste von Section-Referenzen, und die App besitzt ein zentrales Registry, das Section-Namen auf Komponenten abbildet. Jeder Eintrag ist in next/dynamic eingewickelt, genau so, wie es die Doku empfiehlt:

sectionRegistry.tsx
// One central registry: every section variant wrapped in next/dynamic
export const sectionRegistry = {
  'sections.hero': {
    concept_1: dynamic(() => import('./sections/Hero/HeroV1')),
    concept_2: dynamic(() => import('./sections/Hero/HeroV2')),
    // ...10 more hero variants
  },
  'sections.faq': {
    concept_1: dynamic(() => import('./sections/Faq/FaqV1')),
  },
  // ...38 section types, ~100 variants total
}

Eine Server-Komponente löst jede Section zur Render-Zeit anhand ihres Namens auf und rendert sie. Die Seiten sind SSG mit ISR, all das geschieht also auf dem Server – der Client erhält nur HTML plus die für die Hydration nötigen Chunks:

SectionRenderer.tsx
// Server component: picks ONE section by name from CMS data
export async function SectionRenderer({ name, concept, id }) {
  const data = await fetchSectionData(name, id)
  const Component = sectionRegistry[name].concepts[concept]
  return <Component {...data} />
}

Dieses Design ist wirklich gut: Marketer stellen Seiten im CMS zusammen, ohne Deploys, ein Registry bedient 700 Seiten, jede Section-Variante ist unabhängig lazy. Auf dem Papier. Das Bundle erzählte eine andere Geschichte.

Ehrlich messen (die DevTools werden dich anlügen)

Vor jedem Fix brauchten wir eine Messung, der wir trauen konnten. Drei Dinge vergiften naive Messungen im Network-Panel der DevTools:

  • Die Summen in der Fußzeile sind kumulativ, solange das Panel aufzeichnet. Ein paar Sekunden nach dem Load beginnt das Link-Prefetching des Frameworks, im Hintergrund Bundles ANDERER Routen zu ziehen – auf einem untätigen Tab konvergieren die Summen gegen „irgendwann alles" und verbergen jeden First-Load-Gewinn.
  • Browser-Erweiterungen schleusen Megabytes eigener Skripte in deine Messung ein – sogar im Inkognito-Modus, wenn sie dort erlaubt sind. Wir beobachteten, wie eine einzige Erweiterung 2 MB „Seiten-JS" hinzufügte.
  • Aus dem Cache bediente Zeilen ((disk cache) / (memory cache)) übertragen null Bytes über das Netzwerk, ein warmer Reload misst also überhaupt nichts.

Die Zahl, die wirklich zählt – und auf die die Core Web Vitals reagieren – ist das Initial Script Set: die <script>-Tags im serverseitig gerenderten HTML. Genau die blockieren die Hydration. Und sie lässt sich trivial per Skript erfassen:

// Count the REAL first-load JS: the <script> set of the prerendered HTML.
// (DevTools totals lie — more on that below.)
const html = fs.readFileSync('.next/server/app/en.html', 'utf8')
const scripts = [...new Set(
  [...html.matchAll(/<script src="([^"?]+\.js)[^"]*"/g)].map(m => m[1])
)]
let bytes = 0
for (const src of scripts) bytes += fs.statSync('.next' + src).size
console.log(scripts.length, 'chunks,', (bytes / 1024).toFixed(0), 'KB')

Für die Attribution pro Modul aktivierten wir vorübergehend productionBrowserSourceMaps und ordneten jedes generierte Byte mit einem kleinen VLQ-Parser seinem Quellmodul zu. Ein Fallstrick, den man kennen sollte: Turbopack benennt die .map-Datei jedes Chunks mit einem anderen Hash als die .js – lies den sourceMappingURL-Kommentar am Ende des Chunks aus, statt js + '.map' zu raten.

Fünf Sackgassen (damit du sie nicht wiederholst)

Jeder der folgenden Schritte wurde mit einem vollständigen Produktions-Build und der oben beschriebenen Messung verifiziert. Keiner davon bewegte die Zahl. Genau diese Wiederholung ist der Punkt – das Fehlerbild überlebt jedes Werkzeug, das du darauf wirfst, weil all diese Werkzeuge ein anderes Problem lösen.

Sackgasse #1: „Nimm doch einfach next/dynamic"

Es war schon da. Jede der ~100 Varianten war in dynamic() eingewickelt. Das First-Load-JS lag trotzdem bei 5.3 MB. Was auch immer dynamic() verspricht, hier hielt es sich nicht daran – merk dir diesen Gedanken, der Grund kommt gleich.

Sackgasse #2: direktes await import() in der Server-Komponente

Vielleicht ist der Wrapper von next/dynamic das Problem? Im App Router kann eine Server-Komponente einen dynamischen Import direkt awaiten – das offizielle Lazy-Loading-Muster für Bibliotheken. Wir ersetzten das Registry aus dynamic()-Wrappern durch eine Map roher import()-Thunks:

// Dead end #2: replace next/dynamic with a direct server-side import()
const loaders = {
  'sections.hero': { concept_1: () => import('./sections/Hero/HeroV1') },
  // ...
}
export async function SectionRenderer({ name, concept, id }) {
  const data = await fetchSectionData(name, id)
  const { default: Component } = await loaders[name][concept]()
  return <Component {...data} />
}
// Result: byte-for-byte the same megachunk. Nothing changed.

Sackgasse #3: das Registry auf 38 Dateien aufteilen

Nächste Hypothese: Der Bundler verschmilzt die Chunks, weil alle ~100 import()-Aufrufe in einem einzigen Modul liegen. Also generierten wir eine Loader-Datei pro Section-Typ – 38 Dateien, jede hält nur die import()-Thunks ihrer eigenen Varianten, dazu ein schlankes Barrel, um sie per Namen nachzuschlagen.

Ergebnis: Byte für Byte identische Ausgabe. Derselbe Megachunk, dieselbe Größe. Das war das erste wirklich nützliche negative Ergebnis: Bundler gruppieren Chunks nach dem Modulgraphen, nicht nach der Dateiaufteilung deiner import()-Aufrufe. Das Verschieben von Anweisungen zwischen Dateien ist für den Graphen unsichtbar – dieselbe Menge an Modulen bleibt vom selben Resolver aus erreichbar.

Sackgasse #4: Bundler-Konfiguration

Das Chunking von Turbopack ist bewusst nicht konfigurierbar: Es gibt kein Äquivalent zu splitChunks, und webpacks Magic Comments (webpackChunkName und Konsorten) werden ignoriert. Das einzige relevante experimentelle Flag für verschachteltes Async-Chunking ist in Produktions-Builds bereits standardmäßig aktiviert. Es gab buchstäblich keinen Regler mehr zum Drehen.

Sackgasse #5: Wechsel zu webpack (und das Experiment, das alles erklärte)

Webpack IST konfigurierbar, also ließen wir die Site durch next build --webpack laufen. Standardkonfiguration: derselbe Megachunk, ~1 MB, alle Sections. Dann forcierten wir die Sache mit einer cacheGroup pro Section – ein Chunk pro Section-Verzeichnis, enforce: true:

next.config.js (webpack experiment)
config.optimization.splitChunks.cacheGroups.sections = {
  test: /[\\/]components[\\/]sections[\\/]/,
  chunks: 'all',
  minSize: 0,
  enforce: true,
  priority: 50,
  name: (module) => 'section-' + dirNameOf(module), // one chunk per section
}
// Result: the 1 MB megachunk split into 34 neat per-section files...
// ...and the page loaded ALL 34 of them. Same total bytes. Zero win.

Die Chunks teilten sich wunderbar auf – 34 aufgeräumte Dateien pro Section. Und die Seite lud alle 34 davon. Dieselbe Gesamtzahl an Bytes, dieselbe blockierte Hydration, jetzt mit mehr HTTP-Requests. Das war der Moment, in dem das eigentliche Problem unbestreitbar wurde: Wir hatten optimiert, WIE der Code verpackt ist, während das Problem darin lag, welchen Code die Route referenziert.

Die wahre Ursache: Erreichbarkeit, nicht Chunking

Hier ist der Mechanismus. Die Section-Komponenten sind Client-Komponenten ('use client') – sie haben Handler, Slider, Analytics. Wenn ein Server-Komponentenbaum eine Client-Komponente referenziert, muss der Bundler den Chunk dieser Komponente in das Client-Bundle der Route aufnehmen, damit sie hydraten kann. Welche Client-Komponenten referenziert eine CMS-gesteuerte Route? Der Renderer löst Sections über einen Laufzeit-String aus dem CMS auf – statisch betrachtet ist also JEDE Section im Registry von jeder Seite aus erreichbar, die den Renderer verwendet. Der Bundler kann nicht wissen, dass /pricing immer nur 12 davon rendert. Er muss alle ~100 vorbereiten.

next/dynamic hilft nicht, weil die Laziness, die es bietet, clientseitig ist: Es verschiebt lediglich, WANN ein Chunk relativ zum Rendern heruntergeladen wird, aber der Server hat bereits entschieden, was gerendert wird, bevor der Client ein einziges Byte ausführt. Was der Server gerendert hat, muss hydraten; was gerendert werden könnte, muss ausgeliefert oder erreichbar sein. Bei einem zur Laufzeit aufgelösten Registry ist „könnte gerendert werden" gleichbedeutend mit „alles".

Ein Bundler bundlet, was erreichbar ist. Wenn dein Resolver 100 Komponenten erreichen kann, liefert deine Route 100 Komponenten aus – aufgeteilt in einen Chunk oder in vierunddreißig, aber so oder so ausgeliefert. Der einzige echte Hebel ist, die Erreichbarkeit selbst zu verkleinern.

Die Lösung: das Wissen in die Build-Zeit verlagern (Codegen + rewrites)

Die rettende Eigenschaft CMS-gesteuerter SSG-Seiten: Zur Build-Zeit weiß der Server bereits genau, welche Sections jede Seite verwendet – er holt sie aus dem CMS, um das HTML vorzurendern. Das Wissen existiert; es ist bloß in der Laufzeit eingesperrt, wo der Bundler es nicht sehen kann. Also materialisieren wir es in Code, bevor der Bundler läuft.

Ein Codegen-Skript läuft als erster Schritt des Builds (im build-Skript des Pakets verkettet – so bekommt es jede Pipeline gratis: lokal, CI, beide Deploy-Wege):

scripts/generate-landing-routes.mjs
// Runs BEFORE next build (chained in the "build" script).
// 1. Find the routes that render CMS pages (scan for the renderer import).
// 2. Ask the CMS which sections each page actually uses —
//    union across every locale, and across A/B variants.
// 3. Stamp a physical page file per route whose import map contains
//    ONLY those sections. The bundler does the rest.
const pages = await fetchAllCmsPages()           // slug -> sections[]
for (const page of inScope(pages)) {
  const concepts = unionAcrossLocalesAndVariants(page)
  writeFileSync(
    `app/[lang]/(generated)/g/${page.key}/page.tsx`,
    stampTemplate({ page, concepts })            // inline import() per concept
  )
}
writeFileSync('generated-routes.manifest.json', { rewrites })
// Any failure => write nothing => the site behaves exactly as before.

Für jede Seite im Scope stempelt es eine physische Route-Datei, deren Import-Map nur die Sections dieser Seite enthält – die Vereinigung über alle Locales (verschiedene Locales können unterschiedliche Section-Sets haben) und über A/B-Varianten. Für den Bundler ist diese generierte Datei ganz normaler Quellcode mit enger statischer Erreichbarkeit, sodass er ohne jegliche Konfiguration ein kleines Bundle pro Route erzeugt:

app/[lang]/(generated)/g/home/page.tsx (auto-generated)
// AUTO-GENERATED on every build — the "manifest" is literal code.
const LOADERS: SectionLoaders = {
  'sections.hero': {
    concept_5: () => import('@/components/sections/Hero/HeroV5'),
  },
  'sections.faq': {
    concept_1: () => import('@/components/sections/Faq/FaqV1'),
  },
  // ...exactly the 13 section types this page renders. Nothing else.
}

export default async function GeneratedHome({ params }) {
  const { lang } = await params
  return <CmsPage loaders={LOADERS} params={{ lang, slug: 'home' }} />
}

Die generierten Routen leben unter einem hässlichen internen Pfad – Nutzer sehen ihn nie. Ein rewrites()-Block in next.config liest das erzeugte Manifest und mappt die echten URLs in beforeFiles auf die generierten Routen. Die entscheidende Eigenschaft: Fehlt das Manifest oder ist es kaputt, gibt es schlicht keine rewrites, und jede Seite wird von der unangetasteten universellen Route bedient:

next.config.ts
async rewrites() {
  // Missing/broken manifest => zero rewrites => yesterday's behavior.
  // The codegen can never take the site down.
  let generated: Array<{ source: string; destination: string }> = []
  try {
    const manifest = JSON.parse(
      fs.readFileSync(path.join(__dirname, 'generated-routes.manifest.json'), 'utf8')
    )
    if (Array.isArray(manifest?.rewrites)) generated = manifest.rewrites
  } catch { /* fall back to universal routes */ }

  return {
    beforeFiles: [
      ...generated,
      // e.g. { source: '/:lang(en|de|fr|...)', destination: '/:lang/g/home' }
    ],
  }
}

Die Rendering-Kette ist ein Spiegel des Originals – dasselbe Data Fetching, dasselbe SEO, dieselbe Analytics – mit einem Unterschied, der den ganzen Sinn ausmacht: Die Komponenten-Map kommt als Prop an, statt aus dem globalen Registry importiert zu werden. Eine Invariante muss gelten, sonst verpufft der ganze Gewinn: Nichts im Modulgraphen der generierten Route darf das globale Registry importieren. Das schließt indirekte Pfade ein – unser Data-Fetching-Helper importierte das Registry nur, um Query-Templates zu lesen, was jede Section wieder erreichbar gemacht hätte; die Metadaten mussten in ein eigenes, registry-freies Modul ausgelagert werden.

SectionRendererGen.tsx
// Same rendering logic as before — but the component map arrives
// as a PROP from the generated page. Nothing in this chain may import
// the global registry, or every section becomes reachable again.
export async function SectionRendererGen({ loaders, name, concept, id }) {
  const data = await fetchSectionData(name, id)
  const loader = loaders[name]?.[concept] ?? loaders[name]?.['concept_1']
  if (!data || !loader) return null
  const { default: Component } = await loader()
  return <Component {...data} />
}

A/B-Tests überstehen (der Teil, nach dem alle fragen)

CMS-Seiten fahren A/B-Experimente: Middleware wertet pro Request ein Feature-Flag aus und schreibt Nutzer in einem Varianten-Bucket per rewrite auf eine andere Seite unter derselben URL um. Genau dafür existierte die Laufzeit-Auflösung überhaupt erst – wie kommt also ein Build-Zeit-System damit zurecht? Indem es die Verarbeitungsreihenfolge respektiert. In Next.js läuft Middleware immer vor den beforeFiles-rewrites, sodass sich die beiden Schichten kombinieren, statt gegeneinander zu arbeiten:

request /
  │
  ▼
proxy (middleware) — A/B decision, ALWAYS runs first
  ├─ no experiment        → pass through as /en
  ├─ user in CONTROL      → set cookie, pass through as /en
  └─ user in VARIANT      → rewrite to /en/ab/<variant-slug>
  │
  ▼
beforeFiles rewrites — OUR manifest, runs on whatever path proxy chose
  ├─ /en                  → /en/g/home            (generated, small)
  ├─ /en/ab/<variant>     → /en/g/ab-<variant>    (generated, small)
  └─ anything unmatched   → untouched → universal route (fallback)

Der Codegen liest die Experiment-Konfigurationen aus derselben Quelle, die auch die Middleware nutzt (eine bewusste Invariante – kein Drift möglich), und stempelt eine generierte Seite pro Variante, plus ein exaktes rewrite für die interne URL der Variante. Control-Nutzer bekommen die schnelle generierte Seite; Varianten-Nutzer bekommen ihre eigene schnelle generierte Seite; die für den Nutzer sichtbare URL ändert sich nie. Ein Experiment, das nach dem letzten Build erstellt wurde, fällt bis zum nächsten Deploy einfach auf die universelle Route zurück – herabgestuft auf die Performance von gestern, aber niemals kaputt.

Der Fail-open-Vertrag

Das gesamte System ist darauf ausgelegt, auf den Status quo herabzustufen, niemals darunter. Wir durften das in der Produktion aus Versehen beobachten: Beim ersten Deploy stellte die CI-Umgebung die CMS-URL unter einem anderen Variablennamen bereit als die lokalen Builds. Der Codegen fand kein CMS, gab nichts aus – und die Site bediente jede Seite über die universelle Route: Build grün, null Auswirkung auf Nutzer. Ein Fix von zwei Zeilen später erschienen die generierten Routen. Der Vertrag:

  • CMS nicht erreichbar oder eine Seite nicht parsbar → diese Seite (oder alles) wird übersprungen; der Build scheitert nie wegen des Codegens.
  • Kein Manifest → keine rewrites → die universelle Route bedient alles, genau wie vor dem Projekt.
  • Section-Struktur im CMS nach dem Build geändert → die Seite rendert das alte Set bis zum nächsten Deploy (ein Publish-Webhook, der Redeploys auslöst, verkürzt dieses Fenster auf Minuten). Inhaltliche Änderungen sind nicht betroffen – Daten werden weiterhin live über ISR geholt.
  • Generierte Dateien sind gitignored und werden bei jedem Build neu erzeugt; Reviewer prüfen den Generator, nicht 700 gestempelte Dateien.

Ergebnisse

Metrik (Startseite)VorherNachher
First-Load-JS (raw, Initial Script Set)5,127 KB (57 chunks)2,411 KB (39 chunks)
Section-Komponenten im Bundle13716
Der Megachunk aller Sections~1,200 KB0 KB

−53% First-Load-JavaScript, unabhängig auf drei Wegen bestätigt: durch die Messung des Initial Script Set, durch die Source-Map-Attribution pro Modul (exakt die 16 eigenen Komponenten der Seite – 13 gemappte Sections plus ihre Kindkomponenten) und durch ein grep nach Markern über die Chunks des Live-Deployments:

// Verify on the LIVE deployment without source maps: CSS class names
// are embedded in the JS chunks, so grep for section markers.
let all = ''
for (const src of initialScriptsOf('/en')) all += await fetchText(src)

// must be there:  the sections the page renders
console.log(all.includes('HeroV5'))          // true ✅
// must NOT be there: everything else
console.log(all.includes('fortune_wheel'))   // false ✅
console.log(all.includes('holiday_offer'))   // false ✅

Dieselben Zahlen reproduzierten sich auf der Produktions-Preview bis aufs Kilobyte. Eine Erwartung, die man managen sollte: Die GESAMTZAHL in deinen DevTools wird nahezu unverändert aussehen, weil der Router nach dem Load im Hintergrund die (weiterhin universellen) Bundles anderer Routen prefetcht. Das ist in Ordnung – Hintergrund-Prefetch blockiert nichts. Bei Performance geht es um das, was vor der Interaktivität lädt, und genau diese Zahl hat sich halbiert. Lighthouse mit seiner Treemap zeigt das unmissverständlich, falls du einen screenshot-tauglichen Beweis brauchst.

Kompromisse, ehrlich

  • Die Aktualität der Struktur ist an den Deploy gebunden: Das Hinzufügen/Entfernen einer Section im CMS erfordert einen Rebuild, um die optimierte Route zu erreichen (Webhook-getriggerte Deploys reduzieren das Fenster auf Minuten; der Fallback deckt die Lücke ab).
  • Der Codegen parst dein CMS und deine Route-Konventionen – das sind ~400 Zeilen Code, die dir gehören und die du pflegen musst, einschließlich des Parsings von Registry und Route-Props.
  • Skalierung: Hunderte CMS-Seiten → Hunderte einzigartiger Section-Kombinationen (unsere ~700 Seiten ergaben ~380 einzigartige Sets, ein langer Long Tail). Fang mit den traffic-stärksten Seiten hinter einer expliziten Scope-Konstante an; den Tail bedient der Fallback.
  • Das verkleinert nicht das gesamte Site-JS – variantenreiche Bibliotheken und aufwendige Sections bleiben bestehen. Es verkleinert das, was jede Route ausliefert. Der nächste Hebel (das Umwandeln präsentationaler Sections in Server-Komponenten) ist ein separates, größeres Projekt.

Erkenntnisse

  1. next/dynamic schafft Split-Points, keine Garantien. Auf servergesteuerten Seiten wird die Laziness dadurch bestimmt, was die Route erreichen kann, nicht dadurch, wie die Importe geschrieben sind.
  2. Chunking-Konfiguration kann Erreichbarkeit nicht beheben. Wir haben es erschöpfend bewiesen: Den Megachunk in 34 Chunks pro Section aufzuteilen änderte nichts – alle 34 wurden geladen.
  3. Wenn deine Seiten SSG/ISR aus einem CMS sind, existiert die Information, die du brauchst, bereits zur Build-Zeit. Codegen ist die Brücke: Verwandle Laufzeit-Lookups in statische Importe pro Route.
  4. Kombiniere mit der Middleware, bekämpfe sie nicht: Die Middleware entscheidet, WELCHE Seite (A/B), die rewrites entscheiden, WELCHE IMPLEMENTIERUNG dieser Seite. Die Reihenfolge garantiert, dass sie sich stapeln.
  5. Baue Fail-open-Systeme: fehlendes Manifest = die Site von gestern. Unser erster Produktions-Deploy trainierte den Fallback aus Versehen, und die Nutzer bemerkten nie etwas.

Das Muster verallgemeinert sich auf jedes „Registry von Komponenten, das zur Laufzeit anhand von CMS-Daten aufgelöst wird" – Page-Builder, Widget-Systeme, themebare Storefronts. Wenn deine Route alles rendern kann, wird sie alles ausliefern. Lass den Build wissen, was jede Seite tatsächlich ist, und der Bundler wird endlich das tun, was du immer angenommen hast, dass er es tue.