Remix hat sich 2026 endgültig als das Full-Stack-Framework für Web-Standard-konforme React-Apps etabliert. Mit der React Router v7-Migration ist die Grenze zwischen Routing-Library und Framework faktisch verschwunden. Claude Code beherrscht das gesamte Remix-Ökosystem: von file-based Routing über typisierte Loader-Funktionen bis hin zu Optimistic UI und Production Deployment auf Cloudflare Workers.
Wer Remix mit Claude Code entwickelt, merkt schnell: Das Framework belohnt Entwickler, die Web-Primitives verstehen. Keine versteckten Abstraktionen, keine schwarzen Kisten. Ein fetch ist ein fetch, ein Response ist ein Response. Claude Code erklärt nicht nur wie Remix-Code aussieht — es erklärt warum das Framework so designt wurde und hilft beim Debuggen von Netzwerk-Requests direkt in den Browser DevTools.
React Router v7 = Remix v3: Ab 2025 hat Shopify die Remix-Marke in React Router v7 aufgehen lassen. Die Konzepte sind identisch — Loaders, Actions, Error Boundaries bleiben bestehen. Der Package-Name wechselte von @remix-run/react zu react-router. Claude Code kennt beide APIs und migriert Projekte automatisch.
1. Routing & Layouts — File-Based Routes, Nested Layouts, Outlet
Remix nutzt das Dateisystem als Router. Jede Datei im app/routes/-Verzeichnis wird automatisch zu einer Route. Nested Layouts entstehen durch Punkt-Notation oder Verzeichnisstruktur — ohne manuelle Konfiguration.
ROUTING File-Based Route-Struktur
Claude Code generiert die korrekte Dateistruktur für komplexe Routing-Hierarchien sofort:
// app/routes/ — Dateistruktur
app/
routes/
_index.tsx // → /
about.tsx // → /about
blog._index.tsx // → /blog (Index-Route)
blog.$slug.tsx // → /blog/:slug (Dynamisch)
dashboard.tsx // → /dashboard (Layout-Route)
dashboard._index.tsx // → /dashboard/ (Index)
dashboard.settings.tsx // → /dashboard/settings
dashboard.profile.tsx // → /dashboard/profile
$.tsx // → Catch-All (404-Handler)
ROUTING Layout-Route mit Outlet
// app/routes/dashboard.tsx — Layout-Route
import { Outlet, NavLink } from "react-router";
export default function DashboardLayout() {
return (
<div className="dashboard-wrapper">
<nav className="dashboard-nav">
<NavLink
to="/dashboard"
end
className={({ isActive }) =>
isActive ? "nav-link active" : "nav-link"
}
>
Übersicht
</NavLink>
<NavLink
to="/dashboard/settings"
className={({ isActive }) =>
isActive ? "nav-link active" : "nav-link"
}
>
Einstellungen
</NavLink>
</nav>
<main className="dashboard-content">
<Outlet /> // ← Child-Routen werden hier gerendert
</main>
</div>
);
}
ROUTING Index-Routes und Pathless Layouts
// app/routes/_auth.tsx — Pathless Layout (kein URL-Segment)
// Unterstriche = kein eigenes URL-Segment
import { Outlet } from "react-router";
export default function AuthLayout() {
return (
<div className="auth-container">
<div className="auth-card">
<Outlet />
</div>
</div>
);
}
// app/routes/_auth.login.tsx → URL: /login (kein /auth/login)
// app/routes/_auth.register.tsx → URL: /register
// Dynamische Segmente mit Constraints:
// app/routes/users.$userId.posts.$postId.tsx
// → /users/42/posts/7 mit params.userId = "42", params.postId = "7"
Claude Code erkennt automatisch welche Route-Konvention gemeint ist und schlägt die korrekte Dateistruktur vor. Bei komplexen Routing-Anforderungen erklärt es den Unterschied zwischen Layout-Routes (mit eigenem URL-Segment) und Pathless-Layouts (mit Unterstrich-Prefix).
2. Loader Functions — loader(), useLoaderData() und TypeScript
Loader-Funktionen laufen ausschließlich auf dem Server. Sie holen Daten, prüfen Authentifizierung und geben typisierte Responses zurück. Claude Code schreibt vollständig typisierte Loader mit LoaderFunctionArgs aus dem Box heraus.
LOADER Typisierter Loader mit LoaderFunctionArgs
// app/routes/blog.$slug.tsx
import {
useLoaderData,
type LoaderFunctionArgs,
type MetaFunction,
} from "react-router";
import { json } from "react-router";
import { getPost } from "~/models/post.server";
// TypeScript-Interface für Loader-Daten
interface Post {
id: string;
title: string;
content: string;
author: { name: string; avatar: string };
publishedAt: string;
tags: string[];
}
export async function loader({ params, request }: LoaderFunctionArgs) {
const { slug } = params;
// TypeScript weiß: slug kann undefined sein
if (!slug) {
throw new Response("Slug fehlt", { status: 400 });
}
const post = await getPost(slug);
if (!post) {
throw new Response("Artikel nicht gefunden", { status: 404 });
}
// Cache-Header für CDN-Performance
return json<Post>(post, {
headers: {
"Cache-Control": "public, max-age=300, s-maxage=3600",
},
});
}
// Meta-Tags aus Loader-Daten generieren
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [{ title: "Artikel nicht gefunden" }];
return [
{ title: data.title },
{ name: "description", content: data.content.slice(0, 160) },
];
};
export default function BlogPost() {
// Vollständig typisiert — kein any, kein as-Cast
const post = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div className="author">
<img src={post.author.avatar} alt={post.author.name} />
<span>{post.author.name}</span>
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
LOADER Parallele Daten-Abfragen und defer()
// Parallele Abfragen mit Promise.all
export async function loader({ params }: LoaderFunctionArgs) {
// Gleichzeitig laden — nicht nacheinander!
const [user, posts, stats] = await Promise.all([
getUser(params.userId!),
getUserPosts(params.userId!),
getUserStats(params.userId!),
]);
return json({ user, posts, stats });
}
// Streaming mit defer() — kritische Daten sofort, Rest später
import { defer, Await } from "react-router";
import { Suspense } from "react";
export function loader({ params }: LoaderFunctionArgs) {
// Kritisch: await (blockiert das initiale HTML)
const criticalData = getCriticalData(params.id!);
// Nicht-kritisch: Promise (wird gestreamt)
const recommendations = getRecommendations(params.id!);
return defer({
criticalData: await criticalData, // geblockt
recommendations, // Promise (nicht geblockt)
});
}
export default function Page() {
const { criticalData, recommendations } = useLoaderData<typeof loader>();
return (
<div>
<h1>{criticalData.title}</h1>
<Suspense fallback={<p>Lade Empfehlungen...</p>}>
<Await resolve={recommendations}>
{(data) => <RecommendationList items={data} />}
</Await>
</Suspense>
</div>
);
}
Claude Code-Tipp: Bei Loader-Fehlern wirft Remix automatisch einen Fehler zur nächsten ErrorBoundary. Claude Code erklärt den Unterschied zwischen throw new Response() (HTTP-Fehler, für den Nutzer sichtbar) und normalen JavaScript-Exceptions (unerwartete Fehler, 500er).
3. Action Functions — action(), Form, useActionData() und Zod-Validierung
Actions sind das serverseitige Gegenstück zu Loaders — sie verarbeiten Formulardaten, mutieren Daten und geben strukturierte Responses zurück. Claude Code schreibt Actions mit vollständiger Zod-Validierung und typsicheren Fehlermeldungen.
ACTION Action mit Zod-Validierung
// app/routes/dashboard.profile.tsx
import {
useActionData,
Form,
type ActionFunctionArgs,
} from "react-router";
import { json, redirect } from "react-router";
import { z } from "zod";
import { updateUserProfile } from "~/models/user.server";
import { requireUser } from "~/session.server";
// Zod-Schema für Formular-Validierung
const ProfileSchema = z.object({
name: z.string().min(2, "Name muss mindestens 2 Zeichen haben"),
email: z.string().email("Ungültige E-Mail-Adresse"),
bio: z.string().max(500, "Bio darf max. 500 Zeichen haben").optional(),
website: z.string().url("Keine gültige URL").optional().or(z.literal("")),
});
type ActionData = {
errors?: Partial<Record<keyof typeof ProfileSchema.shape, string>>;
success?: boolean;
};
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
// FormData → Plain Object
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
// Zod-Validierung mit safeParse (wirft nicht)
const result = ProfileSchema.safeParse(rawData);
if (!result.success) {
// Feldspezifische Fehlermeldungen zurückgeben
const errors: ActionData["errors"] = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof ProfileSchema.shape;
errors[field] = issue.message;
}
return json<ActionData>({ errors }, { status: 422 });
}
await updateUserProfile(user.id, result.data);
// Nach erfolgreicher Mutation → Redirect (PRG-Pattern)
return redirect("/dashboard/profile?saved=true");
}
export default function ProfileSettings() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" />
{actionData?.errors?.name && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
<div className="field">
<label htmlFor="email">E-Mail</label>
<input id="email" name="email" type="email" />
{actionData?.errors?.email && (
<p className="error">{actionData.errors.email}</p>
)}
</div>
<button type="submit">Profil speichern</button>
</Form>
);
}
ACTION Mehrere Actions in einer Route (Intent-Pattern)
// app/routes/dashboard.posts.$id.tsx
// Mehrere Formulare → eine Action → Intent-Discriminator
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "publish":
await publishPost(params.id!);
return json({ message: "Artikel veröffentlicht" });
case "unpublish":
await unpublishPost(params.id!);
return json({ message: "Artikel zurückgezogen" });
case "delete":
await deletePost(params.id!);
return redirect("/dashboard");
default:
throw new Response("Unbekannte Action", { status: 400 });
}
}
// Im JSX: hidden input mit intent-Wert
function PostActions({ isPublished }: { isPublished: boolean }) {
return (
<div>
<Form method="post">
<input type="hidden" name="intent"
value={isPublished ? "unpublish" : "publish"} />
<button type="submit">
{isPublished ? "Zurückziehen" : "Veröffentlichen"}
</button>
</Form>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<button type="submit" className="danger">Löschen</button>
</Form>
</div>
);
}
4. Error Boundaries — ErrorBoundary, useRouteError(), isRouteErrorResponse()
Remix hat Error Boundaries fest in das Routing-System integriert. Jede Route kann ihren eigenen Fehler-Handler definieren. Claude Code schreibt Error Boundaries, die zwischen HTTP-Fehlern (404, 403) und unerwarteten JavaScript-Exceptions unterscheiden.
ERROR Vollständige ErrorBoundary-Implementierung
// app/routes/blog.$slug.tsx — ErrorBoundary in derselben Datei
import {
useRouteError,
isRouteErrorResponse,
Link,
} from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
// HTTP-Fehler: throw new Response("...", { status: 404 })
if (isRouteErrorResponse(error)) {
return (
<div className="error-page">
<h1>{error.status} {error.statusText}</h1>
{error.status === 404 && (
<div>
<p>Dieser Artikel existiert nicht (mehr).</p>
<Link to="/blog">← Zurück zum Blog</Link>
</div>
)}
{error.status === 403 && (
<div>
<p>Du hast keinen Zugriff auf diesen Inhalt.</p>
<Link to="/login">Einloggen</Link>
</div>
)}
{error.status === 500 && (
<p>Serverfehler. Bitte versuche es später erneut.</p>
)}
</div>
);
}
// Unerwartete JavaScript-Exception
if (error instanceof Error) {
return (
<div className="error-page">
<h1>Unerwarteter Fehler</h1>
<p>{error.message}</p>
{process.env.NODE_ENV === "development" && (
<pre>{error.stack}</pre>
)}
</div>
);
}
// Unbekannter Fehlertyp
return <div>Unbekannter Fehler aufgetreten.</div>;
}
ERROR Root Error Boundary (app/root.tsx)
// app/root.tsx — Globale Fehlerseite als letzter Fallback
import {
Links, Meta, Scripts, ScrollRestoration,
useRouteError, isRouteErrorResponse,
} from "react-router";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// Root ErrorBoundary — fängt alles was Route-Boundaries nicht fangen
export function ErrorBoundary() {
const error = useRouteError();
const is404 =
isRouteErrorResponse(error) && error.status === 404;
return (
<html lang="de">
<head>
<title>{is404 ? "404 – Nicht gefunden" : "Fehler"}</title>
<Meta />
<Links />
</head>
<body>
<main className="error-root">
<h1>{is404 ? "Seite nicht gefunden" : "Etwas ist schiefgelaufen"}</h1>
<a href="/">Zur Startseite</a>
</main>
<Scripts />
</body>
</html>
);
}
Wichtig: Error Boundaries in Remix fangen Fehler aus Loaders, Actions UND dem Render-Prozess. Eine Route ohne eigene ErrorBoundary leitet den Fehler an die nächste Eltern-Route weiter — bis zur Root-ErrorBoundary in app/root.tsx.
5. Optimistic UI — useNavigation(), useOptimistic und Pending States
Optimistic UI aktualisiert die Benutzeroberfläche sofort, bevor der Server antwortet. Remix macht das mit useNavigation() und useFetcher() einfacher als in jedem anderen Framework. Claude Code schreibt dieses Pattern ohne zusätzlichen State-Management-Code.
OPTIMISTIC useNavigation() für Pending States
// app/routes/dashboard.posts._index.tsx
import { useNavigation, Form, useLoaderData } from "react-router";
export default function PostsList() {
const { posts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// Ist gerade eine Submission im Gange?
const isSubmitting = navigation.state === "submitting";
// Welche Action wird gerade ausgeführt?
const submittingIntent = navigation.formData?.get("intent");
const submittingId = navigation.formData?.get("postId");
return (
<ul>
{posts.map((post) => (
<li key={post.id} style={{
// Optimistisch ausblenden während Delete läuft
opacity: isSubmitting
&& submittingIntent === "delete"
&& submittingId === post.id
? 0.4 : 1,
transition: "opacity 0.2s",
}}>
<span>{post.title}</span>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="postId" value={post.id} />
<button
type="submit"
disabled={isSubmitting && submittingId === post.id}
>
{isSubmitting && submittingId === post.id
? "Wird gelöscht..."
: "Löschen"}
</button>
</Form>
</li>
))}
</ul>
);
}
OPTIMISTIC useFetcher() für Inline-Updates ohne Navigation
// Likes, Toggles, Inline-Edits ohne Page-Navigation
import { useFetcher } from "react-router";
function LikeButton({
postId,
initialLiked,
likeCount,
}: {
postId: string;
initialLiked: boolean;
likeCount: number;
}) {
const fetcher = useFetcher();
// Optimistischer Wert: Was wird der neue Zustand sein?
const optimisticLiked = fetcher.formData
? fetcher.formData.get("action") === "like"
: initialLiked;
const optimisticCount = fetcher.formData
? optimisticLiked ? likeCount + 1 : likeCount - 1
: likeCount;
return (
<fetcher.Form method="post" action="/api/likes">
<input type="hidden" name="postId" value={postId} />
<input
type="hidden"
name="action"
value={optimisticLiked ? "unlike" : "like"}
/>
<button
type="submit"
className={optimisticLiked ? "liked" : ""}
aria-label={optimisticLiked ? "Like entfernen" : "Liken"}
>
♥ {optimisticCount}
</button>
</fetcher.Form>
);
}
OPTIMISTIC Globaler Lade-Indikator mit useNavigation()
// app/components/GlobalLoadingBar.tsx
// Zeigt einen Ladebalken bei jeder Navigation
import { useNavigation } from "react-router";
import { useEffect, useRef } from "react";
export function GlobalLoadingBar() {
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
return (
<div
className="loading-bar"
style={{
opacity: isLoading ? 1 : 0,
width: isLoading ? "70%" : "100%",
transition: isLoading
? "width 2s ease"
: "opacity 0.3s ease",
}}
/>
);
}
// In app/root.tsx einbinden:
export default function App() {
return (
<>
<GlobalLoadingBar />
<Outlet />
</>
);
}
React 19 useOptimistic(): Ab React 19 gibt es zusätzlich den nativen useOptimistic()-Hook. Claude Code kennt beide Patterns und empfiehlt je nach Anwendungsfall: useFetcher() für Remix-native Formulare, useOptimistic() für komplexere UI-State-Übergänge.
6. Deployment — Cloudflare Workers, Fly.io, Vite-Plugin und React Router v7 Migration
Remix läuft überall wo JavaScript läuft — Cloudflare Workers, Fly.io, AWS Lambda, Node.js. Der Vite-Plugin ersetzt den alten Compiler vollständig. Claude Code kennt alle Deployment-Targets und generiert die korrekte Konfiguration auf Knopfdruck.
DEPLOY Cloudflare Workers Setup
// vite.config.ts — Cloudflare Workers Adapter
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
export default defineConfig({
plugins: [
cloudflareDevProxy(), // Lokaler Dev-Server mit Cloudflare-APIs
reactRouter({
future: {
unstable_optimizeDeps: true,
},
}),
],
});
// wrangler.toml
// name = "meine-app"
// compatibility_date = "2024-01-01"
// compatibility_flags = ["nodejs_compat"]
// main = "./build/server/index.js"
// assets = { directory = "./build/client" }
// app/entry.server.tsx — Cloudflare-spezifisch
import { createRequestHandler } from "@react-router/cloudflare";
const handler = createRequestHandler({
build: () => import("virtual:react-router/server-build"),
});
export default { fetch: handler };
DEPLOY Fly.io mit Node.js Adapter
// vite.config.ts — Node.js / Fly.io
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
export default defineConfig({
plugins: [reactRouter()],
});
// Dockerfile für Fly.io
// FROM node:20-alpine AS build
// WORKDIR /app
// COPY package*.json ./
// RUN npm ci
// COPY . .
// RUN npm run build
//
// FROM node:20-alpine
// WORKDIR /app
// COPY --from=build /app/build ./build
// COPY --from=build /app/package*.json ./
// RUN npm ci --production
// CMD ["node", "build/server/index.js"]
// fly.toml
// [http_service]
// internal_port = 3000
// force_https = true
// [env]
// PORT = "3000"
DEPLOY React Router v7 Migration aus Remix v2
// Schritt 1: Package-Namen aktualisieren
// package.json — Vorher (Remix v2):
// "@remix-run/react": "^2.x"
// "@remix-run/node": "^2.x"
// "@remix-run/serve": "^2.x"
// "@remix-run/dev": "^2.x"
// Nachher (React Router v7):
// "react-router": "^7.x"
// "@react-router/node": "^7.x"
// "@react-router/serve": "^7.x"
// "@react-router/dev": "^7.x"
// Schritt 2: Import-Pfade aktualisieren (find & replace)
// "@remix-run/react" → "react-router"
// "@remix-run/node" → "@react-router/node"
// Schritt 3: vite.config.ts anpassen
// import { vitePlugin as remix } from "@remix-run/dev"
// → import { reactRouter } from "@react-router/dev/vite"
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
export default defineConfig({
plugins: [reactRouter()],
});
// Schritt 4: react-router.config.ts (neu, optional)
import { type Config } from "@react-router/dev/config";
export default {
appDirectory: "app",
ssr: true, // Server-Side Rendering aktiviert
} satisfies Config;
Automatische Migration: Claude Code kann Remix v2-Projekte vollständig auf React Router v7 migrieren. Der Befehl "Migriere dieses Remix v2-Projekt auf React Router v7" analysiert alle Imports, aktualisiert Package.json und passt die Konfigurationsdateien an — in einem einzigen Schritt.
DEPLOY Environment Variables und Secrets
// app/utils/env.server.ts — Typisierte Umgebungsvariablen
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
NODE_ENV: z.enum(["development", "production", "test"]),
});
// Validierung beim Server-Start — crasht laut bei falscher Konfig
export const env = EnvSchema.parse(process.env);
// Cloudflare Workers: process.env existiert nicht
// → context.cloudflare.env verwenden
export async function loader({ context }: LoaderFunctionArgs) {
const { DB } = context.cloudflare.env; // Typed via wrangler.toml
const data = await DB.prepare("SELECT * FROM posts").all();
return json(data);
}
Fazit: Remix und Claude Code — Web Standards als Wettbewerbsvorteil
Remix ist kein Framework das Entwickler von Web-Standards abschirmt — es ist eines das Web-Standards verstärkt. Wer Remix versteht, versteht HTTP besser. Loader sind Server-Funktionen die Response-Objekte zurückgeben. Actions verarbeiten FormData. Error Boundaries fangen HTTP-Statuscodes.
Claude Code ist der ideale Partner für Remix-Entwicklung, weil es beide Seiten kennt: die Web-Standards die Remix zugrunde liegen und die Framework-Konventionen die darauf aufbauen. Es schreibt typisierte Loader in Sekunden, validiert Action-Daten mit Zod, erklärt Error-Boundary-Hierarchien und generiert produktionsreife Deployment-Konfigurationen für Cloudflare Workers oder Fly.io.
Die React Router v7-Migration ist kein Breaking Change — es ist eine Konsolidierung. Wer heute mit Remix v2 arbeitet, ist auf React Router v7 bereits vorbereitet. Claude Code übernimmt die mechanische Migration und lässt Entwickler sich auf die eigentliche Produktlogik konzentrieren.
Framework-Modul im Kurs
Im Claude Code Mastery Kurs: Remix vs Next.js — vollständiger Vergleich mit realen Projekten, Loaders/Actions, Error Handling und Production Deployment auf Cloudflare Workers.
14 Tage kostenlos testen →