Der Spickzettel für alles, was eine Seite auf den Schirm bringt: Routing, Layouts, die Server-Client-Grenze, Streaming und Caching. Was danach kommt — Formulare, Datenbank, Anmeldung, Tests, Betrieb — steht auf dem zweiten Blatt: Next.js 16 Full-Stack.
Maßgeblich ist und bleibt die offizielle Next.js-Dokumentation; dieses Blatt ist eine Auswahl für den Alltag, keine Referenz. Es beschreibt Next.js 16.3 mit React 19.2 — ältere Anleitungen im Netz weichen an genau den Stellen ab, die hier stehen.
Projekt und Konventionsdateien
npx create-next-app@latest # TypeScript und App Router wählen
Die Antworten beim Anlegen wirken lange nach: Der Ordner src/ und der
Pfad-Alias @ lassen sich später nur mit einer Sammelmigration nachrüsten.
Turbopack ist in 16.3 Standard, keine Option — alte webpack-Konfiguration
greift nicht mehr. Der Build-Cache liegt lokal und hilft auf frischen
CI-Runnern zunächst nicht.
| Datei | Bedeutung |
|---|---|
page.tsx | macht das Verzeichnis zur Route |
layout.tsx | Hülle, bleibt bei Navigation bestehen |
template.tsx | Hülle, wird bei Navigation neu erzeugt |
loading.tsx | Ladezustand der Route |
error.tsx | Fehlergrenze der Route |
not-found.tsx | Antwort auf notFound |
route.ts | Endpunkt statt Seite |
proxy.ts | läuft vor der Anfrage, im Wurzelverzeichnis neben app/ |
Alles ohne diese Namen bleibt gewöhnliche Datei — auch mitten in app/.
Komponenten und Tests dürfen also neben ihrer Route liegen, ohne eine URL zu
erzeugen; ein Ordner mit Unterstrich (_komponenten/) bleibt vollständig aus
dem Routing heraus.
Modul: Einordnung und Projektsetup
Routing: Segmente, Gruppen, Slots
app/page.tsx /
app/liegeplaetze/[nummer]/page.tsx /liegeplaetze/42
app/(portal)/buchungen/page.tsx /buchungen ← Klammern fallen weg
app/dashboard/@belegung/page.tsx benannter Slot, kein eigener Pfad
| Schreibweise | Bedeutung |
|---|---|
[nummer] | genau ein Segment |
[...pfad] | ein oder mehrere Segmente, kommt als Array |
[[...pfad]] | null oder mehr Segmente |
(gruppe) | Struktur ohne URL-Wirkung, eigenes Layout möglich |
_ordner | vollständig aus dem Routing heraus |
Parameter kommen als Promise und sind Benutzereingabe:
export default async function Page(props: PageProps<'/liegeplaetze/[nummer]'>) {
const { nummer } = await props.params // PageProps wird beim Build erzeugt
}
Modul: App Router und Routing-Grundlagen
Layout oder Template
| Layout | Template | |
|---|---|---|
| bei Navigation | bleibt bestehen | neu erzeugt |
| Zustand | erhalten | zurückgesetzt |
| Effekte | laufen einmal | laufen erneut |
| Normalfall | ja | Ausnahme |
Im Zweifel Layout. Template nur, wenn der Zustand ausdrücklich weg soll — ein
mehrstufiger Assistent etwa, der beim zweiten Aufruf keine alten Eingaben
zeigen darf. Das Wurzel-Layout ist verpflichtend und muss <html> und <body>
enthalten.
Parallele Routen (@slot) und abfangende Routen sind seltener richtig, als sie
wirken:
| Fall | Mittel |
|---|---|
| Zwei Bereiche, eigene Ladezustände | parallele Route |
| Detail als Overlay, URL soll teilbar sein | abfangende Route |
| Zwei Bereiche, ein gemeinsamer Ladezustand | gewöhnliche Komponente |
| Inhalt hängt an einem Schalter | Client-Zustand |
Modul: App Router und Routing-Grundlagen
Navigation und Prefetching
Link | useRouter | |
|---|---|---|
| ohne JavaScript | ja | nein |
| Prefetching | ja | nein |
| aus Code auslösbar | nein | ja |
| Client-Komponente nötig | nein | ja |
Partial Prefetching setzt Cache Components voraus, beide Schalter gehören zusammen:
// next.config.ts
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true }
| Einstellung | Umfang |
|---|---|
| Standard | die App-Hülle der Route |
prefetch true am Link | zusätzlich URL-abhängige Inhalte, kostet einen Serveraufruf je Link |
prefetch false | nichts |
Prefetching ist im Entwicklungsmodus abgeschaltet — Navigationsprobleme fallen dort nicht auf.
Modul: Navigation, Prefetching und UI-Zustände
Lade-, Fehler- und Statuszustände
| Mittel | Zuständig für |
|---|---|
loading.tsx | Ladezustand einer ganzen Route |
<Suspense> | Ladezustand eines Teilbaums, näher am Zugriff |
error.tsx | Fehler einer ganzen Route |
global-error.tsx | Fehler, die das Wurzel-Layout treffen |
catchError | Fehler eines einzelnen Teilbaums, mit retry |
Je enger die Grenze, desto mehr Seite bleibt beim Fehler stehen. Für die Antwort an den Aufrufer gibt es vier Aufrufe, und ihre Reihenfolge ist nicht beliebig — erst Identität, dann Recht, dann Existenz:
| Lage | Aufruf | Status |
|---|---|---|
| nicht angemeldet | unauthorized() | 401 |
| angemeldet, kein Recht | forbidden() | 403 |
| Datensatz existiert nicht | notFound() | 404 |
| Ziel hat sich verschoben | redirect() | 307 |
Alle vier werfen eine Kontrollfluss-Ausnahme: Code dahinter läuft nicht mehr,
und ein try-Block, der alles abfängt, verschluckt sie.
Modul: Navigation, Prefetching und UI-Zustände
Server oder Client Component
| Server Component (Standard) | Client Component (begründete Ausnahme) |
|---|---|
| Datenbank und API abfragen | Zustand und Ereignisse |
| Geheimnisse verwenden | useEffect und Lebenszyklus |
| JavaScript im Bundle sparen | Browser-APIs wie localStorage |
| früh sichtbaren Inhalt liefern | eigene Hooks |
'use client' steht ganz oben in der Datei, über allen Importen, und wirkt auf
den Modulgraphen: Alles, was diese Datei importiert, landet im Bundle. Die
Direktive gehört deshalb an den Filterknopf, nicht an die Seite.
Der Ausweg aus fast jedem vermeintlichen Konflikt ist das Slot-Muster — importieren wandert ins Bundle, durchreichen nicht:
<Aufklapper titel="Saison"> {/* Client-Hülle */}
<Saisonstatistik /> {/* bleibt Server Component */}
</Aufklapper>
Was die Grenze passiert, ist im Browser im Klartext lesbar — auch ausgeblendete Felder. Props schmal schneiden statt ganze Datensätze durchreichen:
<GastKarte name={gast.name} boot={gast.bootsname} /> // nicht: gast={gast}
import 'server-only' // ein Import in eine Client-Datei bricht den Build ab
| Form der Variablen | Sichtbarkeit |
|---|---|
DATABASE_URL, SESSION_SECRET | nur Server |
NEXT_PUBLIC_… | im Bundle, öffentlich |
Das Präfix ist keine Einstellung, sondern eine Veröffentlichung — und sie bleibt in bereits ausgelieferten Bundles bestehen.
Modul: Server und Client Components
Daten laden und streamen
const plaetzeP = getLiegeplaetze() // starten …
const rechnungenP = getOffeneRechnungen()
const [plaetze, rechnungen] = await Promise.all([plaetzeP, rechnungenP])
Die Aufrufe starten beim Aufruf, nicht beim await — deshalb erst sammeln.
| Lage | Form |
|---|---|
| Abfragen unabhängig | parallel starten, gemeinsam erwarten |
| zweite braucht Ergebnis der ersten | sequenziell, zweite in Suspense |
| ein Fehler darf nicht alles kippen | allSettled statt all |
| Segmente der Route | laufen ohnehin parallel |
cache() aus React verhindert Doppelarbeit innerhalb eines Requests — der
saubere Weg, die Sitzung überall zu lesen, ohne sie durchzureichen:
export const getSitzung = cache(async () => { /* … */ })
| Fall | Wo geladen wird |
|---|---|
| beim Rendern bekannt | Server Component |
| nach Interaktion nachgeladen | Client mit Bibliothek |
| regelmäßig aktualisiert | Client mit Bibliothek |
| einmal geladen, dann gefiltert | Server laden, Client filtern |
Die letzte Zeile ist der Fall, den man am häufigsten falsch löst. Wer aus dem
Client lesen muss: Das Versprechen auf dem Server starten, nicht erwarten,
durchreichen und im Client mit use() auflösen.
| Laufzeit-API | Quelle |
|---|---|
cookies() | Cookies der Anfrage |
headers() | Kopfzeilen der Anfrage |
searchParams | Abfrageteil der URL |
params | dynamische Pfadsegmente |
Früher zog ein einziger cookies()-Aufruf die ganze Route ins dynamische
Rendern. Heute begrenzt die Suspense-Grenze den Schaden auf ihren Teilbaum —
ältere Dokumentation und Beiträge beschreiben an dieser Stelle noch das alte
Verhalten. Ein Zugriff in generateMetadata zählt genauso wie einer in der
Seite.
Modul: Datenzugriff und Streaming
Caching mit Cache Components
Wer nichts markiert, bekommt frische Daten — das ist der sichere Ausgangszustand. Gecacht wird ausdrücklich:
export async function getBelegung() {
'use cache'
cacheLife('minutes')
cacheTag('belegung') // der Griff, an dem die Server Action später zieht
return db.select().from(belegungen)
}
Argumente gehen in den Schlüssel ein — und alles, was die Funktion aus ihrem Umfeld liest, ebenfalls.
| Daten | Lebensdauer | Grund |
|---|---|---|
| Hafenbeschreibung | Tage | fast statisch |
| Tarifliste | Stunden | ändert sich selten |
| Belegung | Minuten | ändert sich laufend |
| Personenbezogenes | gar nicht | siehe unten |
| Speicherort | Geteilt | Dauerhaft |
|---|---|---|
| Standard, im Prozess | nein | nein — serverlos oft flüchtig |
use cache remote | ja | ja, kostet einen Netzwerkweg je Zugriff |
use cache private | nein | im Browser des Nutzers |
| vorgerendertes HTML | ja | ja |
Ein neuer Build macht alle Einträge ungültig, auch die dauerhaften.
| Aufruf | Wirkung |
|---|---|
revalidateTag | markiert Einträge als veraltet |
updateTag | erneuert die Einträge sofort |
revalidatePath | dasselbe auf Pfadebene |
refresh | aktualisiert den Client-Router, nicht getaggte Daten |
Partial Prerendering setzt das zusammen: statische Hülle vom CDN, gecachte Teile daneben, personalisierte Teile strömen nach.
import { connection } from 'next/server'
await connection() // nötig vor Date.now(), Math.random(), randomUUID()
Modul: Caching mit Cache Components
Typische Fallen
- Ein Ordner ohne
pageist keine Route und liefert 404, auch mitlayout. Die Wurzel-Route heißtapp/page.tsx, nichtapp/index.tsx. paramsist ein Promise. Einawaitdarauf ganz oben macht alles Folgende sequenziell; im Layout verhindert es das Vorrendern des ganzen Zweigs.- Ein Layout rendert bei Navigation im eigenen Zweig nicht erneut. Eine Prüfung allein im Layout läuft deshalb nicht bei jedem Seitenwechsel — und ausgeblendete Inhalte stehen trotzdem in der übertragenen Nutzlast.
- Route Groups sind keine Sicherheitsgrenze, nur eine Strukturhilfe. Zwei Gruppen dürfen nicht dieselbe URL erzeugen, sonst bricht der Build ab.
fetchwird nicht mehr von allein gecacht und blockiert bis zur Antwort.- Ein Layout, das ungecachte Daten liest, fällt nicht auf sein eigenes
loadingzurück — es blockiert die Navigation. Sitzungsdaten im Kopfbereich sind der häufigste Auslöser; einawaitim Wurzel-Layout trifft jede Seite. - Suspense allein macht nichts dynamisch. Synchrone Arbeit läuft trotzdem vorab; Bots bekommen die fertig gerenderte Seite, nicht den Strom.
- Eine gecachte Abfrage, die die Sitzung liest, liefert fremde Daten aus. Der Fehler zeigt sich erst bei zwei gleichzeitigen Nutzern. Wer die Kennung als Argument übergibt, macht sie zum Cache-Schlüssel — das ist der saubere Weg.
- Ein Tag ohne passende Invalidierung ist wirkungslos, und über mehrere Instanzen hinweg wirkt er nur mit koordinierendem Cache-Handler.
- Beim Selbsthosten muss der Reverse Proxy die Pufferung abschalten, sonst kommt vom Streaming nichts an.
Zum Seminar Next.js 16 Full-Stack-Entwicklung mit React und TypeScript