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 hierOhne 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.
# config/routes.rb
resources :notes
get '/lernen', to: 'lernen#index'
get '/lernen/m2-routing', to: 'lernen#m2_routing'// 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: RootLayout → LernenLayout → page.tsx. Genau wie verschachtelte Layouts in Rails, aber deklariert durch Dateistruktur statt render layout:.
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.
# config/routes.rb
resources :notes # generiert /notes/:id
# app/controllers/notes_controller.rb
def show
@note = Note.find(params[:id])
end// 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 → /dashboardTypische 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
}
// ...
}<%# app/views/notes/_note.html.erb %>
<%= link_to note.title,
note_path(note),
class: "block p-4 rounded-lg border" %>// 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.
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>
);
}# app/controllers/application_controller.rb
rescue_from ActiveRecord::RecordNotFound do
render 'errors/not_found', status: 404
end// app/notes/[id]/page.tsx
import { notFound } from "next/navigation";
if (!note) notFound();
// → rendert app/notes/not-found.tsx10. 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.
/lernen/m2-routingKlick auf einen Link — usePathname() aktualisiert sich sofort ohne Page-Reload. Das ist clientseitige Navigation mit dem <Link>-Component.
Lege eine neue Seite unter dem aktuellen Routing-Pfad an und navigiere mit <Link> dorthin:
- Erstelle die Datei
app/lernen/m2-routing/demo/page.tsxmit 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>
);
}- Öffne die Demo oben und klick auf den Link
/lernen/m2-routing/demo. - Beobachte:
usePathname()zeigt jetzt/lernen/m2-routing/demo– ohne Page-Reload. - Füge optional eine
loading.tsxim 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 existiertJede 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.