Remix v3 Nested Routes and Data Loading
Remix nested routes provide a powerful architecture for building full-stack React applications. Unlike traditional single-page app routing, Remix routes are both UI components and API endpoints, with each route segment independently loading its data, handling mutations, and managing errors. This approach eliminates waterfalls, simplifies state management, and delivers excellent performance out of the box.
This guide explores the nested routing model in depth, covering data loading with loaders, mutations with actions, error boundaries, streaming, and the patterns that make Remix applications performant and maintainable at scale.
Understanding Nested Route Architecture
In Remix, the URL structure maps directly to a component hierarchy. Each URL segment corresponds to a route module that owns its data requirements. Moreover, parent routes render outlet components where child routes appear, creating a natural composition model.
URL: /dashboard/projects/42/tasks
Route hierarchy:
├── root.tsx → Layout (nav, footer)
├── dashboard.tsx → Dashboard layout (sidebar)
├── dashboard.projects.tsx → Projects list
├── dashboard.projects.$id.tsx → Single project
└── dashboard.projects.$id.tasks.tsx → Tasks view
Each route loads its OWN data in parallel!Route Module Structure
// app/routes/dashboard.projects.$id.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, Outlet, Link } from '@remix-run/react';
import { requireUser } from '~/utils/auth.server';
import { getProject } from '~/models/project.server';
// Loader: runs on server, fetches data for this route
export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request);
const project = await getProject(params.id!, user.id);
if (!project) {
throw new Response('Project not found', { status: 404 });
}
return json({
project,
permissions: await getPermissions(user.id, project.id),
});
}
// Component: renders with loader data
export default function ProjectPage() {
const { project, permissions } = useLoaderData<typeof loader>();
return (
<div className="project-layout">
<header>
<h1>{project.name}</h1>
<nav>
<Link to="tasks">Tasks</Link>
<Link to="settings">Settings</Link>
<Link to="members">Members</Link>
</nav>
</header>
{/* Child routes render here */}
<Outlet context={{ permissions }} />
</div>
);
}
// Error boundary: handles errors for this route segment
export function ErrorBoundary() {
return <div className="error">Failed to load project</div>;
}Data Loading Patterns
Remix loaders run in parallel for all matched routes. This eliminates the waterfall problem where a parent component must finish loading before children begin fetching data. Additionally, Remix automatically revalidates data after mutations:
// app/routes/dashboard.projects.$id.tasks.tsx
import { json, type LoaderFunctionArgs,
type ActionFunctionArgs } from '@remix-run/node';
import { useLoaderData, useFetcher, useNavigation } from '@remix-run/react';
export async function loader({ params, request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const status = url.searchParams.get('status') || 'all';
const page = parseInt(url.searchParams.get('page') || '1');
const [tasks, total] = await Promise.all([
getTasks(params.id!, { status, page, limit: 20 }),
getTaskCount(params.id!, status),
]);
return json({ tasks, total, page, status });
}
// Action: handles form submissions (mutations)
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const intent = formData.get('intent');
switch (intent) {
case 'create': {
const title = formData.get('title') as string;
const task = await createTask(params.id!, {
title, assigneeId: user.id,
});
return json({ task });
}
case 'toggle': {
const taskId = formData.get('taskId') as string;
await toggleTask(taskId);
return json({ ok: true });
}
case 'delete': {
const taskId = formData.get('taskId') as string;
await deleteTask(taskId);
return json({ ok: true });
}
default:
throw new Response('Invalid intent', { status: 400 });
}
}
export default function TasksPage() {
const { tasks, total, page } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<div>
{/* Optimistic UI with fetcher */}
<fetcher.Form method="post">
<input type="hidden" name="intent" value="create" />
<input name="title" placeholder="New task..." required />
<button disabled={isSubmitting}>Add</button>
</fetcher.Form>
<ul>
{tasks.map(task => (
<li key={task.id}>
<fetcher.Form method="post">
<input type="hidden" name="intent" value="toggle" />
<input type="hidden" name="taskId" value={task.id} />
<button>{task.done ? '✓' : '○'}</button>
</fetcher.Form>
{task.title}
</li>
))}
</ul>
</div>
);
}Streaming with defer
For slow data sources, Remix supports streaming responses with defer. The page renders immediately with available data while slow data streams in:
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
export async function loader({ params }: LoaderFunctionArgs) {
// Fast: wait for this
const project = await getProject(params.id!);
// Slow: stream these in later
const analytics = getAnalytics(params.id!); // no await!
const activity = getActivityFeed(params.id!); // no await!
return defer({
project, // resolved — renders immediately
analytics, // Promise — streams when ready
activity, // Promise — streams when ready
});
}
export default function ProjectDashboard() {
const { project, analytics, activity } = useLoaderData<typeof loader>();
return (
<div>
<h2>{project.name}</h2>
<Suspense fallback={<AnalyticsSkeleton />}>
<Await resolve={analytics}>
{(data) => <AnalyticsChart data={data} />}
</Await>
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Await resolve={activity}>
{(feed) => <ActivityList items={feed} />}
</Await>
</Suspense>
</div>
);
}Error Boundaries and Pending UI
Each route can define its own error boundary. Consequently, an error in a child route does not break the entire page — parent routes continue to function normally:
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h2>{error.status} {error.statusText}</h2>
<p>{error.data}</p>
</div>
);
}
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}When NOT to Use Remix Nested Routes
If your application is primarily a client-side SPA with minimal server interaction, Remix adds unnecessary complexity. Furthermore, highly dynamic dashboards where every widget independently fetches data may work better with client-side data fetching libraries like TanStack Query. Remix also requires a Node.js server, so purely static sites are better served by frameworks like Astro or Next.js with static export.
Key Takeaways
- Remix nested routes provide parallel data loading, eliminating waterfall requests common in SPAs
- Each route module is both a UI component and an API endpoint with loaders and actions
- Streaming with defer enables instant page loads while slow data arrives progressively
- Error boundaries are scoped to route segments, preventing one failure from breaking the entire page
- Best suited for full-stack applications with form-heavy interactions and server-rendered content
Related Reading
- Astro 5 Content Layer and Hybrid Rendering
- React 19 Server Actions and Forms Guide
- Tailwind CSS 4 Migration and New Features