Modul 2

Routing und Navigation

Wenn du bisher Rails entwickelt hast, kennst du das Gefühl: du öffnest config/routes.rb, deklarierst resources :notes, und Rails sorgt dafür, dass GET /notes bei NotesController#index landet. Das Routing ist eine Konfigurationsdatei, die du explizit pflegst.

Next.js dreht dieses Modell um. Es gibt keine routes.rb. Stattdessen ist deine Ordnerstruktur das Routing-System. Wer versteht, wie der App Router Ordner und Dateien interpretiert, versteht schon 80 % des Frameworks.


1. File-based Routing: Ordner = URL-Segment

Im App Router gilt eine einfache Regel: Jeder Ordner innerhalb von app/ entspricht einem URL-Segment. Die Datei page.tsx in diesem Ordner macht das Segment öffentlich erreichbar.

app/
├── page.tsx          → /
├── notes/
│   ├── page.tsx      → /notes
│   └── [id]/
│       └── page.tsx  → /notes/42  (dynamisch)
└── lernen/
    ├── page.tsx      → /lernen
    └── m2-routing/
        └── page.tsx  → /lernen/m2-routing   ← du bist hier

Ohne page.tsx existiert ein Ordner im URL-Space nicht. Er kann Layouts, Hilfsdateien oder Unterordner enthalten – aber erst page.tsx macht ihn zu einer Route. Das ist ein bewusstes Design: Routenstruktur und Dateistruktur kollabieren zu einem einzigen Konzept.

Rails-Analogie
Ruby on Rails
# config/routes.rb
resources :notes
get '/lernen', to: 'lernen#index'
get '/lernen/m2-routing', to: 'lernen#m2_routing'
Next.js
// Keine Konfiguration nötig – der Ordner
// app/lernen/m2-routing/ mit einer page.tsx
// darin genügt vollständig.

2. layout.tsx – der gemeinsame Rahmen

In Rails gibt es app/views/layouts/application.html.erb. Das <%= yield %> darin ist der Platzhalter, in den jede Action ihre View rendert. Das kennt ihr alle.

Next.js hat ein direktes Äquivalent: die layout.tsx-Datei. Jeder Ordner kann eine haben, und sie wrappen alle Kind-Routen. Entscheidend: Layouts werden beim Navigieren nicht neu gerendert – der Status bleibt erhalten. Das entspricht in etwa dem Verhalten, das man mit Hotwire/Turbo in Rails simuliert.

Die Schachtelung funktioniert automatisch:

// Root-Layout – wraps alles. Muss <html> und <body> enthalten.
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="de">
      <body>{children}</body>
    </html>
  );
}
// Lernen-Layout – wraps alle /lernen/* Routen
import Sidebar from "@/components/tutorial/Sidebar";

export default function LernenLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <main className="flex-1 overflow-y-auto">{children}</main>
    </div>
  );
}

Wenn du /lernen/m2-routing aufrufst, rendert Next.js von außen nach innen: RootLayoutLernenLayoutpage.tsx. Genau wie verschachtelte Layouts in Rails, aber deklariert durch Dateistruktur statt render layout:.

Rails-Analogie

In Rails: application.html.erb enthält <%= yield %>, spezifischere Layouts (z. B. lernen.html.erb) können mit layout "lernen" im Controller aktiviert werden. In Next.js entsteht diese Hierarchie automatisch durch die Ordnerstruktur – kein layout: nötig.


3. page.tsx – Controller-Action und View in einer Datei

In Rails trennt ihr strikt zwischen Controller (Logik, Daten laden) und View (HTML rendern). In Next.js fallen diese beiden Konzepte für Server Components zusammen: Eine page.tsx kann Daten direkt laden und HTML zurückgeben.

// Server Component – kein "use client" nötig
// Entspricht: NotesController#index + app/views/notes/index.html.erb
export default async function NotesPage() {
  // Daten direkt laden (kein fetch-Aufruf zu sich selbst nötig)
  const notes = await prisma.note.findMany({ orderBy: { createdAt: "desc" } });

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

Der große Unterschied: In Rails gibt der Controller Instanzvariablen an die View weiter (@notes). In Next.js gibt es diese Trennung nicht – die Komponente ist Controller und View zugleich. Wenn du mehr Struktur brauchst, kannst du Datenlade-Logik in eigene Funktionen oder Services auslagern.


4. Dynamische Segmente [id]

Für Detailseiten, die auf einen Parameter reagieren sollen, umschließt du den Ordnernamen mit eckigen Klammern: [id]. Der Parametername wird dann als params.id an die Komponente übergeben.

// params wird automatisch von Next.js injiziert
export default async function NotePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params; // In Next.js 15 ist params ein Promise
  const note = await prisma.note.findUnique({ where: { id: parseInt(id) } });

  if (!note) notFound(); // Löst die not-found.tsx aus

  return <h1>{note.title}</h1>;
}

Du kannst beliebig tief schachteln: app/users/[userId]/posts/[postId]/page.tsx ergibt params.userId und params.postId. Für Catch-All-Routen gibt es [...slug] (Array aller Segmente) und [[...slug]] (optionaler Catch-All, der auch die Basis-URL matched).

Wichtig für Next.js 15: params ist jetzt ein Promise und muss mit await aufgelöst werden, bevor du auf seine Werte zugreifst.

Rails-Analogie
Ruby on Rails
# config/routes.rb
resources :notes  # generiert /notes/:id

# app/controllers/notes_controller.rb
def show
  @note = Note.find(params[:id])
end
Next.js
// app/notes/[id]/page.tsx
export default async function NotePage({
  params,
}: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const note = await prisma.note.findUnique(...);
}

5. Route Groups (grouping)

Manchmal möchte man Ordner für Struktur oder gemeinsame Layouts nutzen, ohne dass der Ordnername in der URL erscheint. Dafür gibt es Route Groups: ein Ordner in runden Klammern wird von der URL ignoriert.

app/
├── (marketing)/
│   ├── layout.tsx   → eigenes Layout nur für diese Gruppe
│   ├── page.tsx     → /
│   └── about/
│       └── page.tsx → /about
└── (app)/
    ├── layout.tsx   → anderes Layout (z. B. mit Auth-Guard)
    └── dashboard/
        └── page.tsx → /dashboard

Typische Anwendungsfälle: unterschiedliche Layouts für Marketing-Seiten vs. eingeloggte App, Trennung nach Teams oder Features, ohne die URL zu verändern. Das (marketing) und (app) tauchen in keiner URL auf.


6. <Link> – clientseitige Navigation

In Rails rendern Links zu neuen Seiten immer einen kompletten HTTP-Request. Mit Hotwire/Turbo kann man das optimieren, aber es ist opt-in. In Next.js ist clientseitige Navigation der Standard – die <Link>-Komponente übernimmt das automatisch.

import Link from "next/link";

export function NoteCard({ note }: { note: { id: number; title: string } }) {
  return (
    // Kein <a href>! Link übernimmt clientseitige Navigation + Prefetching
    <Link href={`/notes/${note.id}`} className="block p-4 rounded-lg border hover:border-indigo-300">
      {note.title}
    </Link>
  );
}

<Link> prefetcht die Zielroute im Hintergrund, sobald der Link im Viewport sichtbar ist. Beim Klick ist die Seite oft schon geladen – kein Spinner nötig. In der Production-Version passiert das automatisch; im Development-Mode ist Prefetching deaktiviert, um heiße Neuladezyklen zu vermeiden.

Für programmatische Navigation (nach einem Form-Submit, nach einer Action) nutzt du useRouter():

"use client";
import { useRouter } from "next/navigation";

export default function NewNotePage() {
  const router = useRouter();

  async function handleSubmit() {
    await createNote(/* ... */);
    router.push("/notes"); // wie redirect_to notes_path in Rails
  }
  // ...
}
Rails-Analogie
Ruby on Rails
<%# app/views/notes/_note.html.erb %>
<%= link_to note.title,
      note_path(note),
      class: "block p-4 rounded-lg border" %>
Next.js
// components/NoteCard.tsx
import Link from "next/link";

<Link href={`/notes/${note.id}`}
      className="block p-4 rounded-lg border">
  {note.title}
</Link>

7. loading.tsx – automatische Suspense-Boundary

Wenn eine Server Component Daten lädt und das eine Weile dauert, möchte man dem Nutzer etwas zeigen. In Rails gibt es dafür Turbo Frames mit Skeleton-Loading oder Spinner-Partials – alles manuell eingebaut.

Next.js löst das deklarativ: eine loading.tsx im selben Ordner wie page.tsx wird automatisch als Suspense-Fallback verwendet, während die Seite rendert.

// Wird sofort gezeigt, während page.tsx noch Daten lädt
export default function Loading() {
  return (
    <div className="space-y-3 p-8">
      {[...Array(5)].map((_, i) => (
        <div key={i} className="h-16 bg-slate-100 rounded-xl animate-pulse" />
      ))}
    </div>
  );
}

Kein <Suspense> von Hand nötig – das Framework legt die Boundary um page.tsx automatisch. Gleichzeitig behält das Layout (Sidebar, Header) seinen Zustand: nur der Inhaltsbereich zeigt den Skeleton.


8. error.tsx – Error Boundary

In Rails kennt ihr rescue_from im Controller: wenn eine bestimmte Exception geworfen wird, greift der Handler und rendert eine Fehlerseite. Next.js hat dafür error.tsx, das als React Error Boundary agiert.

"use client"; // Error Boundaries müssen Client Components sein

export default function Error({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-xl font-semibold text-red-600 mb-2">
        Etwas ist schiefgelaufen
      </h2>
      <p className="text-slate-600 mb-4">{error.message}</p>
      <button
        onClick={unstable_retry}
        className="px-4 py-2 bg-indigo-600 text-white rounded-lg"
      >
        Erneut versuchen
      </button>
    </div>
  );
}

Das error.tsx fängt Fehler in page.tsx und allen Kind-Komponenten dieser Route. Das Layout darüber bleibt intakt – Navigation und Sidebar verschwinden nicht, nur der fehlerhafte Inhaltsbereich wird ersetzt.

Rails-Analogie

In Rails: rescue_from StandardError, with: :handle_error im Controller. In Next.js: error.tsx im selben Ordner wie page.tsx. Beide leiten Fehler ab, ohne die gesamte Anwendung zum Absturz zu bringen.


9. not-found.tsx – 404-Handling

Der häufigste Fehlerfall bei Detailseiten: eine Resource mit der gesuchten ID existiert nicht. In Rails wirft find eine ActiveRecord::RecordNotFound, die ihr per rescue_from in ein sauberes 404 verwandelt.

In Next.js ruft ihr die Funktion notFound() aus next/navigation auf. Sie bricht das Rendering ab und zeigt stattdessen not-found.tsx:

import { notFound } from "next/navigation";

export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const note = await prisma.note.findUnique({ where: { id: parseInt(id) } });

  if (!note) notFound(); // Wirft intern einen speziellen Error – kein try/catch nötig

  return <h1>{note.title}</h1>;
}
import Link from "next/link";

export default function NoteNotFound() {
  return (
    <div className="p-8 text-center">
      <p className="text-2xl font-semibold text-slate-800 mb-2">Notiz nicht gefunden</p>
      <p className="text-slate-500 mb-6">Diese Notiz existiert nicht oder wurde gelöscht.</p>
      <Link href="/notes" className="text-indigo-600 hover:underline">
        Alle Notizen anzeigen
      </Link>
    </div>
  );
}
Rails-Analogie
Ruby on Rails
# app/controllers/application_controller.rb
rescue_from ActiveRecord::RecordNotFound do
  render 'errors/not_found', status: 404
end
Next.js
// app/notes/[id]/page.tsx
import { notFound } from "next/navigation";

if (!note) notFound();
// → rendert app/notes/not-found.tsx

10. Live-Demo: Routing in Aktion

Die Demo unten zeigt usePathname() in Echtzeit. Klick auf die Links – der Wert aktualisiert sich sofort, weil <Link> clientseitig navigiert und kein Page-Reload stattfindet. Beachte, wie die Sidebar und dieses Layout erhalten bleiben.

M2RouteDemousePathname + Link-Navigation
usePathname()/lernen/m2-routing

Klick auf einen Link — usePathname() aktualisiert sich sofort ohne Page-Reload. Das ist clientseitige Navigation mit dem <Link>-Component.


Eigene Route anlegen

Lege eine neue Seite unter dem aktuellen Routing-Pfad an und navigiere mit <Link> dorthin:

  1. Erstelle die Datei app/lernen/m2-routing/demo/page.tsx mit folgendem Inhalt:
import Link from "next/link";

export default function DemoPage() {
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Ich bin /m2-routing/demo!</h1>
      <p className="text-slate-600 mb-6">
        Diese Seite existiert, weil der Ordner{" "}
        <code>app/lernen/m2-routing/demo/</code> eine{" "}
        <code>page.tsx</code> enthält.
      </p>
      <Link
        href="/lernen/m2-routing"
        className="text-indigo-600 hover:underline font-medium"
      >
        ← Zurück zu M2
      </Link>
    </div>
  );
}
  1. Öffne die Demo oben und klick auf den Link /lernen/m2-routing/demo.
  2. Beobachte: usePathname() zeigt jetzt /lernen/m2-routing/demo – ohne Page-Reload.
  3. Füge optional eine loading.tsx im selben Ordner hinzu, die einen Platzhalter zeigt.

Im Capstone: DevNotes

Die DevNotes-App, die wir durch das Tutorial aufbauen, nutzt genau die Routing-Patterns aus diesem Modul. Hier ist die vollständige Route-Struktur:

app/
├── layout.tsx                  → Root-Layout (HTML-Shell, Fonts)
├── page.tsx                    → / (Landing oder Redirect)

├── (auth)/                     → Route Group: kein /auth/ in der URL
│   ├── login/
│   │   └── page.tsx            → /login
│   └── register/
│       └── page.tsx            → /register

└── notes/
    ├── layout.tsx              → Notes-Layout (Sidebar mit Tag-Filter)
    ├── page.tsx                → /notes (Liste aller Notizen)
    ├── loading.tsx             → Skeleton während Daten laden
    ├── error.tsx               → Fehlerfall (DB-Fehler etc.)
    ├── new/
    │   └── page.tsx            → /notes/new (Formular neue Notiz)
    └── [id]/
        ├── page.tsx            → /notes/42 (Einzelansicht)
        ├── edit/
        │   └── page.tsx        → /notes/42/edit
        └── not-found.tsx       → wenn Notiz nicht existiert

Jede Route folgt derselben Logik: Ordner = URL-Segment, page.tsx = öffentliche Seite, layout.tsx = gemeinsamer Rahmen. Was in Rails über resources :notes plus Controller-Konventionen entsteht, entsteht hier durch Dateistruktur – sichtbar, navigierbar, ohne Magie.