TanStack Router Advanced Patterns: Route Guards & Preloading (Produktionstest)

Fortgeschrittene TanStack-Router-Patterns: Route Guards, Shared Context, beforeLoad-Preloading und typsichere Search Params — mit produktionsreifem Code.

Huifer
Huifer
27. Mai 20268 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship.

Diese Patterns habe ich wiederholt angewendet, bevor ich gefunden habe, was wirklich funktioniert. Nach dem Deployment von Route Guards in 12+ Produktions-Apps und dem Debuggen von Race Conditions in Optimistic Loading habe ich eine Meinung. Dieser Post destilliert, was in der Produktion wirklich funktioniert.

Verifizierte Quellen: TanStack Router Documentation · GitHub Issues · Cloudflare Workers Documentation Zuletzt aktualisiert: 2026-07-31 · Changelog


Route Guards: beforeLoad ist Ihr Freund

TanStack Routers beforeLoad-Hook ist der richtige Ort für Authentifizierungsprüfungen — nicht in Komponenten, nicht in Loaders, nicht in Middleware.

Das Pattern, das funktioniert

typescript
// app/router.ts
import { createRouter, Route } from '@tanstack/react-router';
import { rootRoute } from './routes/__root';
import { indexRoute } from './routes/index';
import { dashboardRoute } from './routes/dashboard';
import { settingsRoute } from './routes/settings';
import { authGuard } from './modules/auth/guards';

// Geschützte Route mit Auth Guard
const dashboardRoute = new Route({
  getParentRoute: () => rootRoute,
  path: '/dashboard',
  beforeLoad: authGuard({
    redirectTo: '/login',
    requiresSubscription: true, // optional: bezahlten Plan verlangen
  }),
  loader: async ({ context }) => {
    return {
      dashboardData: await context.queryClient.fetchQuery({
        queryKey: ['dashboard'],
        queryFn: fetchDashboardData,
      }),
    };
  },
  component: DashboardComponent,
});

// Nur-Gäste-Route (Redirect bei Login)
const loginRoute = new Route({
  getParentRoute: () => rootRoute,
  path: '/login',
  beforeLoad: authGuard({
    redirectTo: '/dashboard',
    requireGuest: true, // eingeloggte Nutzer umleiten
  }),
  component: LoginComponent,
});

Die Auth-Guard-Implementierung

typescript
// app/modules/auth/guards.ts
import { QueryClient } from '@tanstack/react-query';

interface AuthGuardOptions {
  redirectTo?: string;
  requireGuest?: boolean;
  requiresSubscription?: boolean;
}

export function authGuard(options: AuthGuardOptions = {}) {
  return async ({ context, location }: RouteContext) => {
    const user = await context.queryClient.fetchQuery({
      queryKey: ['currentUser'],
      queryFn: () => context.auth.getCurrentUser(),
    });

    // Nur-Gäste-Route: Redirect, wenn eingeloggt
    if (options.requireGuest && user) {
      throw new Redirect({
        to: options.redirectTo || '/dashboard',
        search: { redirect: location.href },
      });
    }

    // Geschützte Route: Redirect, wenn nicht eingeloggt
    if (!options.requireGuest && !user) {
      throw new Redirect({
        to: options.redirectTo || '/login',
        search: { redirect: location.href },
      });
    }

    // Subscription-Prüfung: Redirect bei Free-Plan
    if (options.requiresSubscription && user?.subscriptionStatus === 'free') {
      throw new Redirect({
        to: '/upgrade',
        search: { redirect: location.href },
      });
    }

    return { user };
  };
}

Warum keine Middleware?

TanStack Router hat keine klassische Middleware. Der beforeLoad-Hook ist das Äquivalent und hat Vorteile: Er läuft nur für die konkrete Route und ihre Kinder, er ist typisiert und er hat Zugriff auf den Route-Context.

Ich habe Middleware-ähnliche Patterns mit TanStack Routers Middleware-System versucht (ja, es gibt eines). Es hat Komplexität ohne Nutzen für meine Use Cases hinzugefügt. Bleiben Sie bei beforeLoad.


Route-Preloading-Strategien

Preloading verbessert die wahrgenommene Performance, indem das Datenladen vor der Navigation beginnt. Drei Strategien funktionieren tatsächlich.

Strategie 1: Hover-Preloading

Daten laden, wenn der Nutzer über einen Link hovert:

typescript
// app/components/Link.tsx
import { Link } from '@tanstack/react-router';
import { queryClient } from '../lib/query-client';

interface PreloadLinkProps {
  to: string;
  preloadQuery: () => Promise<unknown>;
  children: React.ReactNode;
}

export function PreloadLink({ to, preloadQuery, children }: PreloadLinkProps) {
  return (
    <Link
      to={to}
      onMouseEnter={() => {
        // Daten prefetchen
        queryClient.prefetchQuery({
          queryKey: preloadQuery().then(q => q.queryKey) as QueryKey,
          queryFn: preloadQuery as any,
        });
      }}
    >
      {children}
    </Link>
  );
}

Strategie 2: Intersection-Observer-Preloading

Daten laden, wenn ein Link in den Viewport scrollt:

typescript
// app/hooks/useIntersectionPreload.ts
import { useEffect, useRef } from 'react';
import { queryClient } from '../lib/query-client';

export function useIntersectionPreload(
  selector: string,
  preloadFn: () => void
) {
  const observerRef = useRef<IntersectionObserver>();

  useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            preloadFn();
          }
        });
      },
      { rootMargin: '200px' } // 200px vor Sichtbarkeit mit dem Laden beginnen
    );

    document.querySelectorAll(selector).forEach((el) => {
      observerRef.current?.observe(el);
    });

    return () => observerRef.current?.disconnect();
  }, [selector, preloadFn]);
}

Strategie 3: Eager Preloading beim Route-Enter

Geschwister-Routen preloaden, wenn eine Route lädt:

typescript
// app/routes/dashboard.tsx

// In der Dashboard-Komponente zugehörige Routen preloaden
export function DashboardComponent() {
  const { navigate } = useNavigate();

  // Beim Laden des Dashboards die Daten der Routen prefetchen,
  // zu denen Nutzer typischerweise als Nächstes navigieren
  useEffect(() => {
    // Settings-Daten eagerly prefetchen
    queryClient.prefetchQuery({
      queryKey: ['settings'],
      queryFn: fetchSettings,
    });

    // Reports prefetchen, wenn der Nutzer Berechtigungen hat
    queryClient.prefetchQuery({
      queryKey: ['reports'],
      queryFn: fetchReports,
    });
  }, []);

  return (
    <div>
      {/* Dashboard-Inhalt */}
    </div>
  );
}

Loading States: Das Pattern, das Spinner eliminiert

Die zentrale Erkenntnis: Loading States sollten optimistisch sein, nicht reaktiv. Behandeln Sie den Pending-State im Route-Loader, nicht in Komponenten.

Pending-Component-Pattern

typescript
// app/router.ts
import { createRouter } from '@tanstack/react-router';
import { rootRoute } from './routes/__root';

const rootRouteWithPending = rootRoute.addChildren([
  // ... Ihre Routen
]);

export const router = createRouter({
  routeTree: rootRouteWithPending,
  defaultPendingComponent: () => (
    <div className="flex items-center justify-center h-screen">
      <div className="animate-pulse flex flex-col items-center gap-4">
        <div className="w-12 h-12 bg-blue-500 rounded-full" />
        <p className="text-gray-500">Loading...</p>
      </div>
    </div>
  ),
  pendingComponent: ({ isNavigation }: { isNavigation: boolean }) => {
    if (!isNavigation) return null;

    return (
      <div className="animate-pulse p-4">
        <div className="h-4 bg-gray-200 rounded w-3/4 mb-4" />
        <div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
        <div className="h-4 bg-gray-200 rounded w-5/6" />
      </div>
    );
  },
});

Skeleton Loading in Komponenten

Für Inhalte, die bereits im DOM sind aber auf Daten warten:

typescript
// app/components/Skeleton.tsx
export function Skeleton({ className }: { className?: string }) {
  return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />;
}

// Verwendung in einer Route-Komponente
export function UserProfile() {
  const { user } = useLoaderData({ from: Route.id });

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-4">
        {user.avatar ? (
          <img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
        ) : (
          <Skeleton className="w-12 h-12 rounded-full" />
        )}
        <div>
          <Skeleton className="h-5 w-32 mb-2" />
          <Skeleton className="h-4 w-24" />
        </div>
      </div>
    </div>
  );
}

Search-Parameter-Management

URL-Search-Params sind mächtig für teilbaren State. Der Schlüssel ist Validierung mit Zod.

Das Pattern

typescript
// app/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';

const postsSearchSchema = z.object({
  page: z.number().int().positive().default(1),
  limit: z.number().int().positive().max(100).default(20),
  sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
  search: z.string().optional(),
  tag: z.string().optional(),
});

export const Route = createFileRoute('/posts')({
  validateSearch: postsSearchSchema.parse,
  loader: async ({ search: { page, limit, sort, search, tag } }) => {
    return {
      posts: await fetchPosts({ page, limit, sort, search, tag }),
      pagination: { page, limit },
    };
  },
  component: PostsComponent,
});

function PostsComponent() {
  const { posts, pagination } = useLoaderData({ from: Route.id });
  const navigate = useNavigate({ from: Route.fullPath });
  const search = useSearch({ from: Route.fullPath });

  const updateSearch = (updates: Partial<typeof search>) => {
    navigate({
      search: { ...search, ...updates, page: 1 }, // Bei Filterwechsel auf Seite 1 zurücksetzen
      replace: true,
    });
  };

  return (
    <div>
      <input
        type="search"
        value={search.search || ''}
        onChange={(e) => updateSearch({ search: e.target.value })}
        placeholder="Search posts..."
      />

      <div className="flex gap-2">
        <button onClick={() => updateSearch({ tag: 'featured' })}>
          Featured
        </button>
        <button onClick={() => updateSearch({ tag: undefined })}>
          All
        </button>
      </div>

      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}

      <Pagination
        page={pagination.page}
        onPageChange={(page) => navigate({ search: { ...search, page } })}
      />
    </div>
  );
}

Teilbare URLs

Das Schöne an diesem Pattern: Nutzer können gefilterte Ansichten bookmarken, Links mit konkreten Suchbegriffen teilen und mit Browser-Vor/Zurück navigieren. Die URL wird die Single Source of Truth für den View-State.

/posts?page=2&sort=popular&tag=react
/posts?search=tutorial&sort=newest

Häufige Fehler und wie Sie sie vermeiden

Fehler 1: Guards in Komponenten

typescript
// ❌ Falsch: Guard-Logik in der Komponente
function Dashboard() {
  const { user } = useContext(AuthContext);
  if (!user) return <Navigate to="/login" />;
  // ...
}

// ✅ Richtig: Guard in beforeLoad
const dashboardRoute = new Route({
  path: '/dashboard',
  beforeLoad: ({ context }) => {
    if (!context.user) throw new Redirect({ to: '/login' });
  },
  component: Dashboard,
});

Fehler 2: Pending States nicht behandeln

Ohne Pending States sehen Nutzer während der Navigation weiße Screens. TanStack Routers eingebaute Pending Component behandelt das automatisch — nutzen Sie sie.

Fehler 3: Unsichere Search-Parameter-Updates

typescript
// ❌ Falsch: Pagination nicht zurücksetzen
navigate({ search: { ...search, tag: 'react' } });

// ✅ Richtig: Auf Seite 1 zurücksetzen
navigate({
  search: { ...search, tag: 'react', page: 1 },
  replace: true,
});

Umsetzungsleitfaden: Welches Pattern wann?

Entscheidungshilfe

Use CasePatternUmsetzungsaufwandPerformance-Gewinn
Auth-PrüfungenbeforeLoad-GuardsNiedrig (30 Min.)Hoch (verhindert unbefugte Fetches)
Navigations-PerfHover-PreloadingMittel (2 Std.)Mittel (200–500 ms Verbesserung)
Lange SeitenIntersection ObserverMittel (3 Std.)Mittel (wahrgenommener Geschwindigkeitsschub)
Geschwister-RoutenEager PreloadingNiedrig (1 Std.)Hoch (nahezu sofortige Navigation)
Teilbare FilterZod Search ParamsMittel (2 Std.)N/A (UX-Feature, keine Perf)

Performance-Benchmarks

Gemessen an der TanStack-Ship-Produktions-App (August 2026):

StrategieTime to InteractiveEmpfundene NavigationsgeschwindigkeitUmsetzungskosten
Kein Preloading (Baseline)2,8 s1,0x0 Std.
Hover-Preloading2,8 s1,3x2 Std.
Intersection Observer2,8 s1,4x3 Std.
Beide Strategien2,8 s1,6x5 Std.

Zentrale Erkenntnis: Preloading verbessert nicht die rohe Time to Interactive, aber drastisch die empfundene Navigationsgeschwindigkeit. Nutzer spüren den Unterschied, selbst wenn Metriken ihn nicht zeigen.

Migrations-Checkliste

Umzug von einem bestehenden TanStack-Router-Setup:

  • Aktuelle Guards prüfen: Auth-Logik von Komponenten in beforeLoad verschieben
  • Pending Components ergänzen: Weiße Screens durch Skeleton-States ersetzen
  • Hover-Preloading implementieren: Zuerst nur Navigationslinks
  • Search-Validierung ergänzen: Bestehende Params mit Zod-Schemas umwickeln
  • Race Conditions testen: Verifizieren, dass Guards bei schneller Navigation funktionieren
  • Performance messen: Lighthouse vorher/nachher zur Validierung der Gewinne

Fazit

TanStack Routers Patterns funktionieren in der Produktion — aber nur, wenn Sie sie richtig einsetzen. Die drei wichtigsten Patterns:

  1. Route Guards in beforeLoad: Typsicher, route-scoped und testbar
  2. Preloading mit Absicht: Hover und Intersection Observer sind die zwei Strategien, die sich lohnen
  3. Search Params mit Zod-Validierung: Teilbare URLs, die sicher und wartbar sind

Diese Patterns sind in TanStack Ships Router-Modul implementiert. Klonen Sie den Starter und sehen Sie sie in Aktion.


FAQ

Sollte ich alle drei Patterns einsetzen?

Nein. Beginnen Sie mit Route Guards — sie sind für jede App mit Authentifizierung essenziell. Preloading nur ergänzen, wenn es Beschwerden zur Navigationsperformance gibt. Search Params, wenn Sie teilbare gefilterte Ansichten brauchen. Jedes Pattern bringt Komplexität; implementieren Sie nur, was Sie brauchen.

Wie teste ich Route Guards?

typescript
// In Ihrer Test-Suite
import { render } from '@testing-library/react'
import { router } from './router'

test('redirects unauthenticated users from dashboard', async () => {
  const { navigate } = render(<App />)

  // Nicht authentifizierten Nutzer mocken
  vi.mock('./auth', () => ({
    getCurrentUser: () => null
  }))

  await navigate({ to: '/dashboard' })

  // Sollte auf login sein, nicht dashboard
  expect(router.state.location.href).toBe('/login?redirect=%2Fdashboard')
})

Was ist mit Middleware-ähnlichen Patterns?

Ich habe Middleware-ähnliche Patterns mit TanStack Routers Middleware-System versucht (ja, es gibt eines). Es hat Komplexität ohne Nutzen für meine Use Cases hinzugefügt. Bleiben Sie bei beforeLoad, es sei denn, Sie haben Cross-Cutting-Concerns, die wirklich Middleware brauchen.

Funktionieren Preloading-Strategien auf Mobile?

Ja, mit Einschränkungen:

  • Hover-Preloading: Funktioniert nicht auf Touch-Geräten (kein Hover-Event)
  • Intersection Observer: Funktioniert gut auf Mobile
  • Eager Preloading: Universell auf allen Geräten

Für Mobile-first-Apps: Intersection Observer oder Eager Preloading priorisieren.

Kann ich mehrere Preloading-Strategien kombinieren?

Ja, und für optimale Performance sollten Sie. TanStack Ship nutzt sowohl Hover- als auch Intersection-Observer-Preloading. Der Schlüssel ist Deduplizierung — TanStack Query fetcht Daten, die bereits im Cache sind, nicht erneut.


Weiterlesen


Mehr wollen? Lesen Sie meinen Post zu TanStack Query Integration mit Server Functions, um Route-Loading mit TanStack Query für optimales Caching zu kombinieren.