Server Components vs. Client Components

Der wichtigste Paradigmenwechsel

Wenn Rails-Entwickler zum ersten Mal mit dem App Router von Next.js arbeiten, stolpern fast alle über dieselbe Frage: „Warte mal – rendert das jetzt im Browser oder auf dem Server?" Die Antwort ist die wichtigste Erkenntnis des gesamten Tutorials: im App Router rendert standardmäßig alles auf dem Server.

Das klingt vertraut – schließlich war genau das in Rails immer so. Aber React hatte jahrelang den gegenteiligen Ruf: eine Browser-Bibliothek, die alles im Client-DOM aufbaut. Next.js kehrt mit den React Server Components (kurz: RSC) dieses Bild um. Die Entscheidung, wo eine Komponente lebt, ist jetzt explizit und bewusst – nicht mehr eine Frage des Frameworks, sondern eine Designentscheidung der Entwicklerin.

Rails-Analogie

In Rails rendert immer der Server: der Controller lädt Daten, die View produziert HTML, das HTML geht an den Browser. JavaScript im Browser war optional und ergänzend (Turbo, Stimulus). Next.js mit dem App Router denkt genauso – aber statt JavaScript nur als optionale Ergänzung zu behandeln, gibt es jetzt eine präzise Trennlinie: Server Components für alles, was kein interaktives Browser-Verhalten braucht, Client Components für alles, was es braucht.

Die Unterscheidung hat handfeste Konsequenzen: Ladezeiten, Bundle-Größen, Datenbankzugriff, SEO – all das hängt davon ab, ob eine Komponente auf dem Server oder im Browser ausgeführt wird.


Server Components – die sichere Basis

Server Components sind der Standard. Du musst nichts schreiben, um eine Komponente zu einer Server Component zu machen – das ist einfach der Ausgangszustand jeder .tsx-Datei im App Router.

Was Server Components können:

  • Direkt auf Datenbanken, Dateisysteme und interne APIs zugreifen – ohne Umweg über eine separate API-Route
  • async/await auf oberster Ebene verwenden – die Komponente darf einfach await-Ausdrücke enthalten
  • Secrets sicher verwenden – Umgebungsvariablen wie API-Keys verlassen niemals den Server
  • Null JavaScript an den Browser schicken – der Output ist reines HTML

Was Server Components nicht können:

  • useState oder useReducer verwenden
  • useEffect oder andere Lifecycle-Hooks nutzen
  • Browser-APIs ansprechen (window, document, localStorage …)
  • Event-Handler direkt anhängen (onClick, onChange …)
// app/notes/page.tsx — Server Component (kein "use client")
// Diese Funktion läuft ausschließlich auf dem Server.

import { prisma } from "@/lib/prisma";

export default async function NotesPage() {
  // Direkt auf die Datenbank zugreifen — kein fetch, kein API-Layer nötig
  const notes = await prisma.note.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <ul>
      {notes.map((note) => (
        <li key={note.id}>{note.title}</li>
      ))}
    </ul>
  );
}
Rails-Analogie
Ruby on Rails
# app/controllers/notes_controller.rb
def index
# Direkt auf die DB zugreifen — kein Umweg
@notes = Note.order(created_at: :desc)
end

# app/views/notes/index.html.erb
<% @notes.each do |note| %>
<li><%= note.title %></li>
<% end %>
Next.js
// app/notes/page.tsx (Server Component)
export default async function NotesPage() {
const notes = await prisma.note.findMany({
  orderBy: { createdAt: "desc" },
});

return (
  <ul>
    {notes.map((n) => <li key={n.id}>{n.title}</li>)}
  </ul>
);
}

Ein kritischer Unterschied zu Rails: In Rails ist der Controller von der View getrennt. In Next.js ist das Datenladen direkt in die Komponente integriert – kein separater Controller, keine Instanzvariablen, kein assign. Die Komponente ist gleichzeitig Controller und View.


Client Components – gezielt einsetzen

Client Components werden mit der Direktive "use client" am Anfang der Datei markiert. Diese Direktive ist eine Grenze: alles in dieser Datei (und alles, was sie importiert, falls nicht selbst als Server Component markiert) wird Teil des JavaScript-Bundles, das an den Browser gesendet wird.

Client Components werden zweimal ausgeführt: einmal auf dem Server (für das initiale HTML, das der Browser sofort anzeigen kann – SSR) und dann erneut im Browser (Hydration, damit Event-Handler aktiv werden). Das ist wichtig für Hydration-Mismatches: wenn Server-Output und Client-Output unterschiedlich sind (z. B. weil new Date() zu verschiedenen Zeitpunkten aufgerufen wird), wirft React eine Warnung.

"use client";

// Diese Direktive muss die erste Zeile der Datei sein.
// Sie markiert die Grenze zwischen Server- und Client-Welt.

import { useState } from "react";

export default function LikeButton({ noteId }: { noteId: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <button
      onClick={() => setLiked((prev) => !prev)}
      className={liked ? "text-red-500" : "text-slate-400"}
    >
      {liked ? "♥ Gemerkt" : "♡ Merken"}
    </button>
  );
}
Rails-Analogie

Ein Client Component ist wie ein Stimulus-Controller oder eine React-on-Rails-Komponente: JavaScript läuft im Browser, reagiert auf User-Events, verwaltet lokalen Zustand. Der Unterschied: in Next.js ist das kein nachträglicher Add-on, sondern ein gleichberechtigter Teil der Architektur. Server Components und Client Components arbeiten im selben Komponentenbaum zusammen.


Server vs. Client: die Live-Demo

Hier siehst du den Unterschied direkt. Die Server Component rendert einmal beim Request – ihr Timestamp friert in dem Moment ein, in dem die Seite geladen wird. Die Client Component tickt weiter, weil sie setInterval im Browser ausführt.

M3ServerInforendert einmal — Timestamp ist eingefroren
Server Component
timestamp: 2026-06-26T18:36:39.203Z
node: v24.18.0
M3ClientClockaktualisiert sich jede Sekunde im Browser
Client Component
timestamp:
updates: live (every second)

Scrolle nach oben und lade die Seite neu: der Timestamp von M3ServerInfo ändert sich (neuer Request, neues Render auf dem Server), während M3ClientClock einfach weitertickend startet.


Entscheidungstabelle: Was braucht was?

| Bedarf | Server Component | Client Component | |---|---|---| | Datenbankabfrage | ✅ direkt | ❌ nur über API-Route | | useState / useReducer | ❌ | ✅ | | useEffect | ❌ | ✅ | | Event-Handler (onClick …) | ❌ | ✅ | | Browser-APIs (window …) | ❌ | ✅ | | Secrets / Env-Vars sicher halten | ✅ verlassen Server nie | ⚠️ nur PUBLIC_-Vars | | Bundle-Größe | ✅ kein JS-Output | ➕ zählt zum Bundle | | SEO / initiales HTML | ✅ sofort verfügbar | ✅ durch SSR | | async/await auf Top-Level | ✅ | ❌ |

Die Faustregel: Beginne immer mit einer Server Component. Wechsle zu Client Component, sobald du Interaktivität, Browser-APIs oder reaktiven Zustand brauchst.


Kompositionsmuster: Server lädt, Client zeigt

Das häufigste und empfohlene Muster ist die Kombination: eine Server Component lädt die Daten und gibt sie als Props an eine Client Component weiter. So bleibt der teure Datenbankaufruf auf dem Server, während die interaktive UI im Browser läuft.

// app/notes/[id]/page.tsx — Server Component
import LikeButton from "./LikeButton"; // Client Component
import { prisma } from "@/lib/prisma";

export default async function NotePage({
  params,
}: {
  params: { id: string };
}) {
  // Datenbankzugriff auf dem Server
  const note = await prisma.note.findUniqueOrThrow({
    where: { id: params.id },
  });

  return (
    <article>
      <h1>{note.title}</h1>
      <p>{note.content}</p>
      {/* LikeButton ist Client Component — bekommt Daten als Props */}
      <LikeButton noteId={note.id} initialLiked={note.liked} />
    </article>
  );
}
"use client";

// components/LikeButton.tsx — Client Component
// Empfängt serialisierbare Props vom Server.
// WICHTIG: Props müssen JSON-serialisierbar sein (keine Klassen, keine Funktionen).

import { useState } from "react";

export default function LikeButton({
  noteId,
  initialLiked,
}: {
  noteId: string;
  initialLiked: boolean;
}) {
  const [liked, setLiked] = useState(initialLiked);

  async function toggle() {
    setLiked((prev) => !prev);
    // Server Action oder fetch-Aufruf würde hier folgen
  }

  return (
    <button onClick={toggle}>
      {liked ? "♥ Gemerkt" : "♡ Merken"}
    </button>
  );
}

Ein wichtiger Constraint: Props, die vom Server an Client Components übergeben werden, müssen JSON-serialisierbar sein. Keine Klassen-Instanzen, keine Funktionen, keine Date-Objekte ohne Konvertierung. Strings, Numbers, Arrays, Plain Objects – das sind die erlaubten Typen.


Streaming mit Suspense

Server Components können kombiniert mit Suspense gestreamt werden. Statt auf die langsamste Datenbankabfrage zu warten, bevor irgendwas an den Browser geschickt wird, sendet Next.js das Grundgerüst sofort und streamt langsame Teile nach.

// app/notes/page.tsx
import { Suspense } from "react";
import NoteList from "./NoteList"; // async Server Component
import NoteListSkeleton from "./NoteListSkeleton"; // Placeholder

export default function NotesPage() {
  return (
    <main>
      <h1>Meine Notizen</h1>

      {/*
        Suspense zeigt NoteListSkeleton, bis NoteList fertig geladen hat.
        Next.js streamt NoteList als separaten Chunk nach.
      */}
      <Suspense fallback={<NoteListSkeleton />}>
        <NoteList />
      </Suspense>
    </main>
  );
}
// app/notes/NoteList.tsx — async Server Component
import { prisma } from "@/lib/prisma";

export default async function NoteList() {
  // Simuliert eine langsame Abfrage
  const notes = await prisma.note.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <ul>
      {notes.map((note) => (
        <li key={note.id}>{note.title}</li>
      ))}
    </ul>
  );
}

Das entspricht konzeptionell Turbo Frames in Rails: ein Teil der Seite lädt unabhängig, der Rest ist sofort nutzbar. Der Unterschied: Streaming passiert auf HTTP-Ebene innerhalb desselben Requests – kein separater AJAX-Call.

useState in einer Server Component

Öffne /components/demos/M3ServerInfo.tsx in deinem Editor. Die Datei hat kein "use client" am Anfang.

  1. Füge am Anfang der Datei import { useState } from "react" hinzu.
  2. Füge im Komponenten-Body const [x, setX] = useState(0) hinzu.
  3. Starte den Dev-Server neu (npm run dev) und öffne die Seite.

Was passiert? Next.js wirft einen Fehler: „You're importing a component that needs useState. It only works in a Client Component but none of its parents are marked with 'use client'."

Das ist der Compiler, der die Grenze schützt. Er erkennt, dass useState ein Browser-Hook ist und verbietet seine Verwendung in Server Components – nicht erst zur Laufzeit, sondern beim Build. Das ist einer der handfesten Vorteile gegenüber einer rein konventionsbasierten Trennung.

Mache die Änderung rückgängig, bevor du weitermachst.


Im Capstone: DevNotes

In der DevNotes-Applikation, die du im Laufe des Tutorials baust, begegnet dir diese Trennung überall:

Server Components übernehmen das Datenladen:

  • app/notes/page.tsx – lädt die Notizliste direkt aus der Datenbank via Prisma
  • app/notes/[id]/page.tsx – lädt eine einzelne Notiz mit ihren Tags
  • app/layout.tsx – liest die aktuelle Session aus dem Cookie und gibt Userdaten weiter

Client Components übernehmen Interaktivität:

  • components/notes/NoteForm.tsx – verwaltet Formularfelder mit useState, sendet Server Actions
  • components/notes/TagBadge.tsx – könnte als Server Component bleiben (kein State), aber wenn du Filter-Toggles hinzufügst, wird es eine Client Component
  • Die Sidebar-Navigation mit aktivem Link-Highlighting braucht usePathname() – ein Hook, der nur im Browser funktioniert

Das typische Muster in DevNotes: eine page.tsx als Server Component lädt die Daten, rendert das Grundgerüst und gibt die Daten als Props an ein Client Component-Formular weiter. Du wirst dieses Muster in Modul 5 konkret implementieren, wenn Server Actions ins Spiel kommen.