Startseite / Artikel

Migrieren von next/router: Params, flache URLs und 404-Fehler im App Router

Next/router hinter sich lassen: Wie params, useParams, oberflächliche URL-Änderungen, programmgesteuerte Navigation sowie echte 404-Seiten im Next.js App Router funktionieren.

Verlag: REACTAPP.TOP6 Min. Lesezeit1340 Wörter

Quellen: Next.js routing: the four next/router habits I had to unlearn

Entwickler, die aus dem Pages Router kommen, greifen oft immer noch auf import { useRouter } from 'next/router' zurück. In einem App Router-Projekt ist diese Import-Methode irreführend: Die Moduldatei existiert zwar weiterhin, es kann sogar eine Typüberprüfung erfolgen, doch router.query gibt leere Werte zurück und der Fehler ist nicht offensichtlich. Die Navigation im App Router (der Standard seit Next.js 13) befindet sich in next/navigation, und mehrere bekannte Idiome des Pages Routers existieren einfach nicht mehr. Diese Anleitung behandelt die vier Änderungen, auf die Sie stoßen werden, wenn Sie einen kleinen Blog auf den App Router portieren, mit Code, der zum Zeitpunkt der Erstellung in Next.js 16.3.1 und React 19 funktioniert.

Dynamische Parameter werden als Prop übergeben, nicht über einen Hook

In dem Pages Router bestand der übliche Ansatz darin, die Seite zu einem Client-Komponenten zu machen und router.query.id abzurufen. Im App Router ist eine Seite standardmäßig ein Server-Komponente, es sei denn, man gibt ansonsten an, und Next.js übermittelt ihr eine params-Eigenschaft. Seit Next.js 15 ist params ein Promise, weshalb die Komponente async sein muss und dieses Promise mit await abwarten muss, bevor sie einen Wert abruft.

Die Seite importiert notFound, damit sie abbrechen kann, wenn der Slug nicht auf einen Beitrag passt:

import { notFound } from 'next/navigation'

Die unten stehende Komponente sucht den Beitrag in einem im Speicher gespeicherten Map nach und ruft notFound() für unbekannte IDs auf.

const POSTS: Record<string, { title: string; body: string }> = {
  'hello-world': { title: 'Hello World', body: 'The first post.' },
}export default async function BlogPost({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = POSTS[id]
  if (!post) notFound()  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  )
}

Es gibt hier weder einen Hook noch die Anweisung 'use client', und die Abfrage wird bereits auf dem Server durchgeführt, bevor irgendwelche HTML-Inhalte gesendet werden. Eine Anfrage an /blog/hello-world bei der Produktionsversion liefert den Titel und den Inhalt des Beitrags mit einem Status 200 zurück. Das Auslassen von await params ist der häufigste Fehler bei der Migration zu Next.js 15 und neueren Versionen: Der Wert, den man destrukturiert, ist eine Promise und nicht die ID.

Auslesen des Segments auf der Client-Seite mit useParams

Server-first Rendering bedeutet nicht, dass jede Komponente auf dem Server ausgeführt wird. Ein Kommentarformular benötigt die Beitrag-ID im Browser. Dafür verwendet man useParams aus next/navigation anstelle von router.query. Die Datei beginnt mit folgender Anweisung:

'use client'

Dann liest die Komponente den entsprechenden Segment aus. Das Übergeben eines Typarguments an useParams liefert ein typisiertes Ergebnis:

import { useParams } from 'next/navigation'export default function CommentBox() {
  const { id } = useParams<{ id: string }>()
  return <form data-post={id}>Commenting on {id}</form>
}

Im Gegensatz zur Version des Pages Routers gibt er hier niemals null zurück: Das Segment wird bereits vor dem Rendern beliebiger darunterliegender Client-Komponenten gelöst. Innerhalb der Post-Seite enthält das Formular bereits bei der ersten Darstellung den Wert data-post="hello-world", wodurch kein Ladezustand erforderlich ist.

Shallow Routing wird zu einem einfachen History API Aufruf

Der Pages Router ermöglicht es, mit router.push(url, as, { shallow: true }) die URL zu ändern, ohne Daten erneut abrufen zu müssen. Der App Router verfügt nicht über eine shallow-Option. Um die URL für Dinge wie Sortierordnung, Filter oder den aktiven Tab ohne Neustart der Server-Komponenten zu aktualisieren, muss man selbst window.history.pushState aufrufen und den Wert anschließend über useSearchParams abrufen. Auch hier handelt es sich um eine Client-Komponente:

'use client'

Die Steuerung kopiert die aktuellen Parameter, setzt sort und fügt den neuen Abfragesatz hinzu; newest dient als Ersatzlösung:

import { useSearchParams } from 'next/navigation'export default function SortControls() {
  const searchParams = useSearchParams()
  const active = searchParams.get('sort') ?? 'newest'  function setSort(sort: string) {
    const next = new URLSearchParams(searchParams.toString())
    next.set('sort', sort)
    window.history.pushState(null, '', `?${next.toString()}`)
  }  return (
    <div>
      <button aria-pressed={active === 'newest'} onClick={() => setSort('newest')}>
        Newest
      </button>
      <button aria-pressed={active === 'top'} onClick={() => setSort('top')}>
        Top
      </button>
    </div>
  )
}

Next.js synchronisiert die nativen Aufrufe von pushState und replaceState mit seinem Router, sodass useSearchParams den neuen Wert erkennt und die Schaltflächen ohne Serveraufruf aktualisiert werden. Zwei Fallstricke sind zu beachten:

  • Eine Komponente, die useSearchParams aufruft, sollte innerhalb einer <Suspense>-Grenze liegen. Ohne diese greift die Seite auf Client-Side Rendering bis zur nächsten Grenze zurück anstelle eines statischen Renderings, und die Build-Ausgabe warnt davor.
  • pushState fügt bei jedem Klick einen Eintrag in die Historie hinzu. Wenn das Zurückgehen durch mehrere Sortierungsänderungen die Benutzer stört, sollte stattdessen replaceState verwendet werden.

Programmgesteuerte Navigation verwendet weiterhin useRouter, von einem neuen Pfad aus

Wenn die Navigation über den Router erfolgen soll, beispielsweise um den Benutzer nach dem Absenden eines Formulars nach Hause zu schicken, bleibt useRouter das geeignete Werkzeug. Nur der Importpfad ändert sich. Zudem wird die entsprechende Direktive benötigt:

'use client'

Der Aufruf selbst bleibt unverändert:

import { useRouter } from 'next/navigation'export default function GoHome() {
  const router = useRouter()
  return <button onClick={() => router.push('/')}>Home</button>
}

router.push('/') verhält sich wie zuvor; das eigentliche Problem liegt in der Importzeile.

Kundenspezifische 404-Seiten sind Dateien, die einen echten 404-Status senden

Die alte Konvention mit pages/404.js wird durch eine Datei namens app/not-found.tsx ersetzt, die immer dann gerendert wird, wenn notFound() aufgerufen wird – auch aus dem Serverkomponenten im ersten Abschnitt. Die Seite verweist zurück auf die Startseite, weshalb Link importiert wird:

import Link from 'next/link'

Die Komponente selbst besteht aus gewöhnlichem Markup:

export default function NotFound() {
  return (
    <main>
      <h1>404 — no such post</h1>
      <Link href="/">Back to the index</Link>
    </main>
  )
}

Der Statuscode ist wichtig: Ein „sanftes“ 404, eine fehlerhaft aussehende Seite, die mit 200 geliefert wird, kann die Suchindexierung beeinträchtigen, da Crawler sie als echten Inhalt behandeln. Der Aufruf einer fehlenden Seite wie /blog/nope gibt HTTP 404 zusammen mit dem Text „no such post“ zurück. notFound() setzt diesen Status, sodass das, was Crawler sehen, dem entspricht, was Benutzer sehen.

Lassen Sie die Build-Ausgabe die Migration bestätigen

Sobald die Migration abgeschlossen ist, ist die von next build ausgegebene Route-Tabelle der schnellste Weg, um das Ergebnis zu überprüfen:

Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /blog/[id]

Der Marker ƒ zeigt an, dass /blog/[id] dynamisch gerendert wird, da hier Parameter ausgelesen werden, während ○ die statischen Index- und Nicht gefundenen Routen kennzeichnet. Kein Teil des Codes setzt auf next/router, router.query oder shallow: true. Zur Entscheidung, was wo ausgeführt wird, siehe wo die Trennlinie zwischen Server und Client in einer App Router-Seite gezogen werden sollte.

Haupterkenntnisse

  • Suchen Sie zunächst im Codebasis nach from 'next/router'; jede Trefferstelle weist auf eine dieser vier Änderungen hin.
  • Lesen Sie dynamische Segmente in Server-Komponenten aus der wartenden params-Eigenschaft und in Client-Komponenten aus useParams.
  • Ersetzen Sie shallow: true durch history.pushState oder replaceState zusammen mit useSearchParams, eingebettet in <Suspense>.
  • Bleiben Sie bei useRouter für echte Navigationen, das aus next/navigation importiert wird.
  • Verwenden Sie app/not-found.tsx zusammen mit notFound(), um einen echten 404-Status anstelle eines milden 404 zu erhalten.