App Router in Next.js: Layout, Page und Route

1. Übersicht

App Router ist der Standard-Routing-Mechanismus von Next.js seit Version 13 und ersetzt schrittweise Pages Router (pages/). Er basiert auf einer Dateikonvention im app/-Verzeichnis: page.tsx erstellt eine Route, layout.tsx erstellt eine gemeinsam genutzte UI, route.ts erstellt einen API-Endpoint. Nach diesem Artikel wirst du wissen, wie du ein Next.js-Projekt mit App Router organisierst, verschachtelte Layouts schreibst, dynamische Routen und Route Handler erstellst und wie du diese Komponenten testest.

2. Grundstruktur des Verzeichnisses

App Router nutzt Ordner = URL-Segment. Jeder Ordner in app/ kann spezielle Dateien enthalten, die das Routing-Verhalten bestimmen.

app/
├── layout.tsx          # Root Layout, muss <html>, <body> haben
├── page.tsx             # Route "/"
├── about/
│   └── page.tsx         # Route "/about"
├── products/
│   ├── layout.tsx       # Layout nur für /products/*
│   ├── page.tsx         # Route "/products"
│   └── [id]/
│       └── page.tsx     # Route "/products/123"
└── api/
    └── orders/
        └── route.ts     # API "/api/orders"
  • layout.tsx umhüllt alle page.tsx auf der gleichen Ebene oder tiefer.
  • page.tsx ist der tatsächliche Endpunkt, der Inhalte für eine URL rendert.
  • route.ts rendert keine UI, sondern gibt eine Response ähnlich wie ein REST-Endpoint zurück.

Hinweis: Ordner, die nicht mit einem Kleinbuchstaben beginnen (z.B. Products), funktionieren zwar, aber Next.js empfiehlt, durchgehend kebab-case oder camelCase zu verwenden, um Verwechslungen auf Betriebssystemen mit Groß-/Kleinschreibung zu vermeiden.

3. Page — Route definieren

Die Datei page.tsx exportiert eine React-Komponente als Haupt-UI für die Route. Standardmäßig ist sie eine Server Component.

// app/products/page.tsx
import { ProductService } from "@/services/product-service";

export default async function ProductsPage() {
  const products = await ProductService.findAll();

  return (
    <main>
      <h1>Produktliste</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name} - {product.price}đ</li>
        ))}
      </ul>
    </main>
  );
}
  • async function ProductsPage ermöglicht await von Daten direkt in der Komponente, da dies eine Server Component ist – kein useEffect oder getServerSideProps nötig.
  • Next.js rendert HTML auf der Serverseite automatisch und sendet es bereits mit Inhalten zum Browser – gut für SEO und erste Anzeigzeit.
  • Die Route entspricht der URL /products.

3.1. Dynamische Route mit Parametern

// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";

type ProductPageProps = {
  params: { id: string };
};

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const product = await ProductService.findById(params.id);

  if (!product) {
    notFound();
  }

  return <h1>{product.name}</h1>;
}
  • Der Ordner [id] erstellt ein dynamisches Segment, Next.js injiziert den tatsächlichen Wert in params.id.
  • notFound() rendert die nächste not-found.tsx-Datei statt die App zum Absturz zu bringen – das ist der Standard-Weg zur Behandlung von 404ern im App Router.

4. Layout — gemeinsam genutzte UI

Layout empfängt den children-Prop für untergeordnete Routen und behält seinen State bei der Navigation zwischen Pages im gleichen Layout (z.B. wird die Sidebar nicht neu gerendert).

// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="products-layout">
      <aside>Produktfilter</aside>
      <section>{children}</section>
    </div>
  );
}
  • children hier ist der Inhalt von app/products/page.tsx oder app/products/[id]/page.tsx.
  • Beim Navigieren von /products zu /products/123 wird der <aside>-Bereich nicht unmountet/remountet – das ist der Hauptvorteil von Layout gegenüber dem manuellen Importieren einer gemeinsam genutzten Komponente in jeder Page.

4.1. Root Layout ist erforderlich

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Shop Nón Lá",
  description: "Demo-Shop mit App Router",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="vi">
      <body>
        <header>Shop Nón Lá</header>
        {children}
      </body>
    </html>
  );
}
  • Root Layout muss <html> und <body> enthalten, da es das höchste Layout ist und die gesamte App umhüllt.
  • export const metadata ist der deklarative Weg, um <title>, <meta> zu deklarieren, Next.js injiziert es automatisch in <head> – kein manuelles DOM-Handling nötig.

Hinweis: Wenn du app/layout.tsx vergisst, wird Next.js es beim Build sofort melden, da jede Route mindestens ein Root Layout benötigt.

5. Route Groups und Parallel Segments

Manchmal möchtest du Routen gruppieren, um Code zu organisieren, aber nicht, dass die Gruppe in der URL erscheint. Nutze Route Groups mit Klammern (name).

app/
├── (marketing)/
│   ├── layout.tsx       # Layout nur für Marketing-Seiten
│   ├── page.tsx         # Route "/"
│   └── pricing/
│       └── page.tsx     # Route "/pricing"
└── (dashboard)/
    ├── layout.tsx       # Layout nur für Dashboard
    └── settings/
        └── page.tsx     # Route "/settings"
  • (marketing) und (dashboard) erscheinen nicht in der URL, sie helfen nur, verschiedene Layouts für verschiedene Seitengruppen zu verwenden.
  • /pricing und /settings verwenden unterschiedliche Layouts, obwohl sie beide unter app/ liegen.

6. Route Handlers — APIs im App Router bauen

Die Datei route.ts ersetzt pages/api/*.ts von Pages Router und exportiert Funktionen nach HTTP-Methoden-Namen.

// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET() {
  const orders = await OrderService.findAll();
  return NextResponse.json(orders);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const order = await OrderService.create(body);
  return NextResponse.json(order, { status: 201 });
}
  • GET und POST sind zwei separate Exports, Next.js routet Requests automatisch nach HTTP-Methode zur richtigen Funktion.
  • NextResponse.json(...) setzt Content-Type: application/json automatisch, du brauchst nicht selbst JSON.stringify.
  • Dieser Endpoint wird unter /api/orders bereitgestellt und ist komplett unabhängig von der UI – es wird kein React gerendert.

6.1. Dynamischer Route Handler

// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const order = await OrderService.findById(params.id);

  if (!order) {
    return NextResponse.json({ message: "Order not found" }, { status: 404 });
  }

  return NextResponse.json(order);
}
  • Wie dynamische Pages ermöglicht der Ordner [id], den Wert aus der URL über params.id zu erhalten.
  • Gib status: 404 explizit zurück, statt dass der Client den Fehler selbst erraten muss.

7. Spezielle Dateien: loading, error, not-found

App Router nutzt React Suspense und Error Boundary implizit durch Konventionsdateien, du brauchst nicht selbst einen Boundary zu schreiben.

// app/products/loading.tsx
export default function ProductsLoading() {
  return <p>Produktliste wird geladen...</p>;
}
// app/products/error.tsx
"use client";

export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>Fehler: {error.message}</p>
      <button onClick={reset}>Erneut versuchen</button>
    </div>
  );
}
  • loading.tsx wird automatisch angezeigt, während page.tsx des gleichen Segments await-Daten aufbaut – kein useState(isLoading) nötig.
  • error.tsx muss eine Client Component sein ("use client"), da es reset verwendet – eine Client-seitige Funktion zum Neu-Rendern des fehlerhaften Segments.
  • Next.js umhüllt Segments automatisch mit entsprechenden <Suspense> und Error Boundaries basierend auf diesen Dateien.

8. Metadata und verschachtelte Layouts

Jedes Segment kann sein eigenes metadata exportieren, Next.js merged sie in Reihenfolge vom Root Layout zur Page.

// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";

type Props = { params: { id: string } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await ProductService.findById(params.id);
  return { title: product ? `${product.name} - Shop Nón Lá` : "Nicht gefunden" };
}

export default async function ProductDetailPage({ params }: Props) {
  const product = await ProductService.findById(params.id);
  return <h1>{product?.name}</h1>;
}
  • generateMetadata ist eine dynamische Funktion, die es dir erlaubt, <title> basierend auf echten Produktdaten zu setzen – hilfreich für SEO auf Detailseiten.
  • Metadata von untergeordneten Pages überschreiben Felder mit gleichem Namen vom parent Layout, andere Felder bleiben bestehen.

9. Page-Komponenten testen

Bei asynchronen Server Components ist der einfachste Test-Weg, die Komponentenfunktion direkt aufzurufen und das zurückgegebene JSX zu prüfen, oder React Testing Library nach dem Rendern zu verwenden.

<!-- package.json (devDependencies, gekürzt) -->
<dependencies>
  <vitest>^2.0.0</vitest>
  <testing-library-react>^16.0.0</testing-library-react>
</dependencies>
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";

vi.mock("@/services/product-service", () => ({
  ProductService: {
    findAll: vi.fn(),
  },
});

describe("ProductsPage", () => {
  it("zeigt die vom Service zurückgegebene Produktliste an", async () => {
    (ProductService.findAll as any).mockResolvedValue([
      { id: "1", name: "Nón lá A", price: 50000 },
    ]);

    const jsx = await ProductsPage();
    render(jsx);

    expect(screen.getByText("Nón lá A - 50000đ")).toBeInTheDocument();
  });
});
  • ProductsPage() wird wie eine reine async Funktion aufgerufen, da sie grundlegend nur eine Funktion ist, die JSX zurückgibt – kein spezielles Test-Framework für Server Components nötig.
  • vi.mock ersetzt ProductService.findAll durch einen Stub, der gefälschte Daten zurückgibt, sodass der Test nicht von der echten Datenbank abhängig ist.
  • render(jsx) aus Testing Library prüft, dass das gerendertes JSX den erwarteten Inhalt im DOM korrekt darstellt.

10. Häufige Fehler und wann du es nicht verwenden solltest

  • Vergessen von "use client" beim Verwenden von Hooks wie useState/useEffect: Server Components unterstützen diese Hooks nicht, Next.js wird einen klaren Build-Fehler anzeigen.
  • Sensible Datenabfrage-Logik (API-Schlüssel) in Client Components platzieren: Da der Code ins Browser-Bundle kommt. Bevorzuge immer Datenabfragen in Server Components oder Route Handlers.
  • Route Groups missbrauchen nur für “schönere” Organisation: Wenn nicht wirklich verschiedene Layouts nötig sind, machen Route Groups die Verzeichnisstruktur nur unübersichtlicher.
  • pages/ und app/ für die gleiche Route mischen: Die beiden Router können nicht die gleiche URL handhaben, Next.js bevorzugt app/ und ignoriert Routes in pages/ – leicht zu Verwechslungen bei gradueller Migration.

Hinweis: Wenn dein Projekt mit Pages Router gut läuft und du Server Components, Streaming oder Server Actions nicht brauchst, ist eine sofortige Migration zum App Router nicht immer nötig. Bewerte die Vorteile, bevor du das komplette Routing umstellst.

11. Fazit

App Router organisiert eine Next.js-Anwendung durch drei Hauptdatei-Typen: page.tsx definiert Routes, layout.tsx teilt UI zwischen mehreren Routes, und route.ts baut APIs ohne HTML-Rendering. Kombiniert mit Konventionsdateien wie loading.tsx, error.tsx, not-found.tsx, kannst du geschmeidige Loading-/Error-Erlebnisse bauen, ohne Boundaries selbst zu schreiben. Beim Entwurf deiner Verzeichnisstruktur gruppiere nach wirklich gemeinsamem Layout, meide unnötige Route Groups und prüfe immer die Server/Client-Component-Grenzen, um sensible Logik nicht zum Browser zu leaken.

Next.jsApp RouterReactServer ComponentsRouting