React-Anwendungen existieren in einem Browser-Tab als kontinuierlicher JavaScript-Prozess. Was wie eine Website mit vielen Seiten wirkt, ist technisch eine einzige HTML-Seite, deren Inhalt sich dynamisch verändert. Diese Architektur – Single Page Application – bringt enorme Vorteile für Responsivität und Benutzererfahrung, schafft aber ein fundamentales Problem: Wie bildet man verschiedene Anwendungszustände in der URL ab? Wie funktionieren Browser-Navigation, Bookmarks und das Teilen von Links?
React selbst kennt kein Routing. Die Bibliothek rendert Komponenten als Reaktion auf State-Änderungen, kümmert sich aber nicht um URLs. Hier setzt React Router an – die de-facto-Standard-Lösung für clientseitiges Routing, die URLs und Komponenten verbindet, ohne die Browser-History zu brechen oder das SPA-Paradigma zu kompromittieren.
React Router arbeitet nach einem Provider-Pattern. Der Router umschließt die gesamte Anwendung und stellt einen Kontext bereit, den alle untergeordneten Komponenten nutzen können. Dieser Kontext enthält die aktuelle URL, Navigationsmethoden und die Browser-History.
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/products">Products</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/products" element={<ProductList />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
Das Routes-Element ersetzt das ältere
Switch aus React Router v5. Es implementiert einen
intelligenten Matching-Algorithmus, der automatisch die spezifischste
Route wählt. Die Reihenfolge der Route-Definitionen ist
weniger kritisch als früher, nur Catch-All-Routen mit
path="*" müssen am Ende stehen.
Link-Komponenten sehen aus wie HTML-Anker, verhalten
sich aber fundamental anders. Ein Klick sendet keine HTTP-Anfrage an den
Server. Stattdessen ändert React Router die Browser-URL programmatisch
und rendert die passende Komponente. Das DOM wird aktualisiert, der
JavaScript-Prozess läuft weiter – keine vollständige Seitenladung, keine
flackernden Übergänge.
React Router bietet verschiedene Router-Implementierungen für unterschiedliche Deployment-Szenarien. Die Wahl des Routers ist eine Architekturentscheidung mit weitreichenden Konsequenzen für SEO, Server-Konfiguration und URL-Ästhetik.
BrowserRouter nutzt die HTML5 History API für saubere
URLs ohne technische Artefakte. Eine Route wie
/products/electronics/laptops wird genau so in der
Browser-Adresszeile angezeigt – keine Hashes, keine
Query-Parameter-Tricks.
import { BrowserRouter } from 'react-router-dom';
<BrowserRouter>
<App />
</BrowserRouter>
Die History API bietet Methoden wie pushState() und
replaceState(), die es ermöglichen, die URL zu ändern, ohne
eine neue HTTP-Anfrage auszulösen. BrowserRouter nutzt
diese Funktionen, um Navigation nahtlos zu implementieren.
Der Preis für saubere URLs ist Server-Konfiguration. Da alle Routen
clientseitig verwaltet werden, muss der Server für jeden URL-Pfad
dieselbe index.html ausliefern. Ohne diese Konfiguration
führen direkte URL-Aufrufe oder Browser-Refreshs zu 404-Fehlern.
# Apache .htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule># Nginx Konfiguration
location / {
try_files $uri $uri/ /index.html;
}
HashRouter nutzt den Fragment-Identifier
(#) für Routing. URLs sehen aus wie
https://example.com/#/products/123. Alles nach dem Hash
wird traditionell nicht an den Server gesendet – der Server sieht nur
die Basis-URL.
import { HashRouter } from 'react-router-dom';
<HashRouter>
<App />
</HashRouter>
Das macht HashRouter ideal für statisches Hosting ohne
Kontrolle über Server-Konfiguration: GitHub Pages, Netlify mit
Default-Settings, S3-Buckets. Die Deployment-Komplexität sinkt
drastisch, aber die URLs werden weniger elegant.
SEO ist ein weiterer Faktor. Moderne Suchmaschinen haben verbesserte
Hash-Fragment-Unterstützung, aber BrowserRouter-URLs
bleiben überlegen für Indexierung und Social-Media-Sharing.
| Aspekt | BrowserRouter | HashRouter |
|---|---|---|
| URL-Format | /products/123 |
/#/products/123 |
| Server-Config nötig | Ja | Nein |
| SEO-Qualität | Optimal | Akzeptabel |
| Browser-Support | Moderne Browser | Alle Browser |
| Deployment-Komplexität | Mittel | Niedrig |
MemoryRouter verwaltet die gesamte Routing-History im
JavaScript-Speicher, ohne Browser-URL-Integration. Die Adresszeile
bleibt unverändert, Navigation ist komplett intern.
import { MemoryRouter } from 'react-router-dom';
<MemoryRouter initialEntries={['/products', '/cart']} initialIndex={0}>
<App />
</MemoryRouter>
Der primäre Anwendungsfall sind Unit-Tests. Router-abhängige Komponenten können isoliert getestet werden, ohne echte Browser-Navigation zu simulieren.
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
test('renders product page', () => {
render(
<MemoryRouter initialEntries={['/products/123']}>
<ProductDetail />
</MemoryRouter>
);
expect(screen.getByText('Product Details')).toBeInTheDocument();
});
Ein zweiter Anwendungsfall sind eingebettete Widgets oder iframes, wo die Host-Seiten-URL nicht beeinflusst werden soll. Das Widget navigiert intern, die äußere Seite bleibt unberührt.
StaticRouter ist für Server-Side Rendering konzipiert.
Er rendert basierend auf einer bekannten URL, ohne Interaktivität oder
Navigation zu unterstützen.
import { StaticRouter } from 'react-router-dom/server';
// Server-seitiger Code
app.get('*', (req, res) => {
const html = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
res.send(`<!DOCTYPE html><html>...${html}...</html>`);
});
Der Server generiert vollständig gerenderten HTML-String, schickt ihn zum Client, wo React die Kontrolle übernimmt (Hydration). Suchmaschinen erhalten complete HTML, Initial Page Load ist schneller, JavaScript-Fehler brechen nicht die gesamte Seite.
Moderne Frameworks wie Next.js abstrahieren diese Komplexität. Für
custom SSR-Setups bleibt StaticRouter essentiell.
Statische Routen reichen für “About Us” oder “Contact”. Realistische Anwendungen brauchen dynamische Segmente: Produkt-IDs, Benutzernamen, Kategorien. React Router bietet zwei komplementäre Mechanismen.
URL-Parameter werden durch Doppelpunkte markiert und schaffen
Platzhalter für variable Werte. Eine Route /user/:userId
matched /user/123 oder /user/anna-schmidt.
<Route path="/user/:userId" element={<UserProfile />} />
<Route path="/products/:category/:productId" element={<ProductDetail />} />
Der useParams-Hook extrahiert diese Werte:
import { useParams } from 'react-router-dom';
interface UserParams {
userId: string;
}
function UserProfile() {
const { userId } = useParams<UserParams>();
if (!userId) {
return <Navigate to="/users" replace />;
}
// userId ist garantiert string
const numericId = parseInt(userId, 10);
if (isNaN(numericId)) {
return <div>Ungültige User-ID</div>;
}
return <div>User Profile für ID: {numericId}</div>;
}
Wichtig: Alle Parameter sind Strings und können
undefined sein, selbst wenn die Route-Definition sie als
erforderlich markiert. Runtime-Validierung ist nicht optional.
interface ProductParams {
category: string;
productId: string;
}
function ProductDetail() {
const { category, productId } = useParams<ProductParams>();
// Beide Parameter extrahieren und validieren
if (!category || !productId) {
return <Navigate to="/products" replace />;
}
// Jetzt sicher verwendbar
return (
<div>
<h1>{category}</h1>
<ProductInfo id={productId} />
</div>
);
}
Query-Parameter verfeinern den Seitenzustand, ohne die grundlegende
Identität zu ändern. Sie folgen dem Fragezeichen:
?page=2&search=react&sortBy=date.
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1', 10);
const search = searchParams.get('search') || '';
const sortBy = searchParams.get('sortBy') || 'name';
const updatePage = (newPage: number) => {
setSearchParams(prev => {
prev.set('page', String(newPage));
return prev;
});
};
const updateSearch = (term: string) => {
setSearchParams(prev => {
if (term) {
prev.set('search', term);
prev.set('page', '1'); // Reset bei neuer Suche
} else {
prev.delete('search');
}
return prev;
});
};
return (
<div>
<input
value={search}
onChange={(e) => updateSearch(e.target.value)}
placeholder="Suche..."
/>
<Pagination current={page} onChange={updatePage} />
<Products filters={{ search, sortBy, page }} />
</div>
);
}
Query-Parameter-Updates sollten bei Suchfeldern debounced werden. Direktes Update bei jeder Tasteneingabe erzeugt Performance-Probleme.
function SearchBox() {
const [searchParams, setSearchParams] = useSearchParams();
const [localSearch, setLocalSearch] = useState(
searchParams.get('search') || ''
);
useEffect(() => {
const timeoutId = setTimeout(() => {
setSearchParams(prev => {
if (localSearch) {
prev.set('search', localSearch);
} else {
prev.delete('search');
}
return prev;
});
}, 300);
return () => clearTimeout(timeoutId);
}, [localSearch, setSearchParams]);
return (
<input
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
/>
);
}
React Router bietet sowohl deklarative als auch imperative Navigation. Die Wahl hängt vom Kontext ab: Deklarativ für Komponenten-Struktur, imperativ für Event-Handler und asynchrone Logik.
Die Navigate-Komponente triggert eine Umleitung während
des Renderings. Sie ersetzt das veraltete Redirect aus
v5.
import { Navigate } from 'react-router-dom';
function Dashboard() {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <div>Welcome, {user.name}</div>;
}
Der replace-Parameter ist kritisch. Mit
replace={true} wird der History-Eintrag ersetzt statt einen
neuen hinzuzufügen. Bei Authentifizierungs-Flows verhindert das, dass
Nutzer über den Zurück-Button zur Login-Seite zurückkehren, nachdem sie
bereits eingeloggt sind.
<Navigate to="/dashboard" replace /> // Ersetzt aktuellen History-Eintrag
<Navigate to="/dashboard" /> // Fügt neuen History-Eintrag hinzu
State kann zwischen Routen übertragen werden:
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace
/>
);
}
return <>{children}</>;
}
function LoginPage() {
const location = useLocation();
const navigate = useNavigate();
const from = location.state?.from || '/';
const handleLogin = async (credentials: Credentials) => {
await authenticate(credentials);
navigate(from, { replace: true }); // Zurück zur ursprünglichen Route
};
return <LoginForm onSubmit={handleLogin} />;
}
Der useNavigate-Hook liefert eine Funktion für
programmatische Navigation – ideal für Event-Handler, nach API-Calls
oder in komplexer Geschäftslogik.
import { useNavigate } from 'react-router-dom';
function ProductForm() {
const navigate = useNavigate();
const handleSubmit = async (data: ProductData) => {
try {
const product = await createProduct(data);
navigate(`/products/${product.id}`, { replace: true });
} catch (error) {
// Fehlerbehandlung
}
};
return <form onSubmit={handleSubmit}>...</form>;
}
Relative Navigation ist ebenfalls möglich:
navigate(-1); // Zurück
navigate(-2); // Zwei Schritte zurück
navigate(1); // Vorwärts
navigate('/products'); // Absolute Navigation
navigate('../'); // Relative Navigation
Options-Objekt bietet feinere Kontrolle:
navigate('/dashboard', {
replace: true, // History-Eintrag ersetzen
state: { fromCheckout: true } // State mitgeben
});
Private Routen sind die erste Verteidigungslinie gegen unbefugten Zugriff. React Router selbst implementiert keine Authentifizierung – das Pattern kombiniert Routing mit React-Konzepten wie Context und bedingtem Rendering.
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Token aus localStorage lesen und validieren
const token = localStorage.getItem('auth_token');
if (token) {
validateToken(token)
.then(setUser)
.catch(() => localStorage.removeItem('auth_token'))
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
const login = async (credentials: Credentials) => {
const { user, token } = await authenticate(credentials);
localStorage.setItem('auth_token', token);
setUser(user);
};
const logout = () => {
localStorage.removeItem('auth_token');
setUser(null);
};
if (isLoading) {
return <LoadingSpinner />;
}
return (
<AuthContext.Provider value={{
user,
isAuthenticated: !!user,
login,
logout
}}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Protected Route-Wrapper prüft den Auth-Status und leitet um:
interface ProtectedRouteProps {
children: ReactNode;
requiredRole?: string;
}
function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const { user, isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredRole && user?.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
}
// Verwendung in Routes
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/admin" element={
<ProtectedRoute requiredRole="admin">
<AdminPanel />
</ProtectedRoute>
} />
</Routes>
Für granulare Berechtigungen erweitert sich das Pattern:
interface Permission {
resource: string;
action: string;
}
function hasPermission(user: User, resource: string, action: string): boolean {
return user.permissions.some(
p => p.resource === resource && p.action === action
);
}
interface PermissionGuardProps {
children: ReactNode;
resource: string;
action: string;
fallback?: ReactNode;
}
function PermissionGuard({
children,
resource,
action,
fallback = null
}: PermissionGuardProps) {
const { user } = useAuth();
if (!user || !hasPermission(user, resource, action)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Verwendung
<PermissionGuard resource="products" action="delete">
<DeleteProductButton />
</PermissionGuard>
Die Router-Wahl beeinflusst das Deployment fundamental.
BrowserRouter benötigt Server-Config,
HashRouter funktioniert überall.
Für Cloud-Anbieter gibt es spezifische Patterns:
Vercel/Netlify: _redirects oder
vercel.json konfigurieren SPA-Fallback:
// vercel.json
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}# Netlify _redirects
/* /index.html 200
AWS S3 + CloudFront: Error-Pages auf
index.html umleiten mit 200-Status statt 404.
Docker/Node Server: Express-Fallback-Route:
app.use(express.static('build'));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});React Router ist für Performance optimiert, aber einige Patterns können Probleme verursachen.
Lazy Loading von Route-Komponenten reduziert Initial Bundle Size:
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
<Routes>
<Route path="/dashboard" element={
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
} />
<Route path="/products" element={
<Suspense fallback={<LoadingSpinner />}>
<Products />
</Suspense>
} />
</Routes>
Parameter-abhängige Daten sollten mit Libraries wie React Query geladen werden:
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
function ProductDetail() {
const { productId } = useParams<{ productId: string }>();
const { data, isLoading, error } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId!),
enabled: !!productId
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return null;
return <Product data={data} />;
}
React Query cached automatisch, dedupliziert Requests und managed Loading-States – alles Parameter-sensitiv.
Normale <a>-Tags statt
<Link> brechen das SPA-Paradigma. Jeder Klick
triggert eine vollständige Seitenladung.
// ❌ Falsch: Full Page Reload
<a href="/products">Products</a>
// ✓ Richtig: Client-Side Navigation
<Link to="/products">Products</Link>
Router außerhalb der Komponenten-Hierarchie führt zu Fehlern:
// ❌ Falsch
function App() {
return (
<div>
<Navigation /> {/* Versucht useNavigate() */}
<BrowserRouter>
<Routes>...</Routes>
</BrowserRouter>
</div>
);
}
// ✓ Richtig
function App() {
return (
<BrowserRouter>
<Navigation />
<Routes>...</Routes>
</BrowserRouter>
);
}
Fehlende 404-Route zeigt leere Seiten bei unbekannten URLs:
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* Immer eine Catch-All-Route */}
<Route path="*" element={<NotFound />} />
</Routes>
Race Conditions bei asynchronen Navigate-Calls:
function Form() {
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(true);
useEffect(() => {
return () => setIsMounted(false);
}, []);
const handleSubmit = async (data: FormData) => {
const result = await saveData(data);
// Nur navigieren wenn Komponente noch existiert
if (isMounted) {
navigate('/success');
}
};
return <form onSubmit={handleSubmit}>...</form>;
}
React Router verbindet URLs mit React-Komponenten, ohne das SPA-Paradigma zu kompromittieren. Die richtige Wahl von Router-Typ, Parameter-Strategie und Auth-Pattern macht den Unterschied zwischen einer funktionierenden und einer professionellen Anwendung.