Skip to content
All essays
WebJanuary 15, 202612 min

Mastering Remix Routing: Nested Routes and Layouts

Deep dive into Remix routing system, including nested routes, dynamic segments, route configuration, and advanced patterns

Ü
Ümit Uz
Mobile & Full Stack Developer

Remix's routing system is one of its most powerful features. Built on React Router v6, it provides a clean, intuitive way to manage nested layouts, data loading, and URL structure. This comprehensive guide covers everything you need to master routing in Remix.

Route File Conventions

Remix uses file-based routing with specific naming conventions:

Basic Routes

app/routes/
├── _index.tsx          # / (index)
├── about.tsx           # /about
├── blog.tsx            # /blog
├── blog._index.tsx     # /blog (parent + index)
└── contact.tsx         # /contact

Dynamic Segments

app/routes/
├── posts.$postId.tsx       # /posts/:postId
├── users.$username.tsx     # /users/:username
├── files.$.tsx            # Wildcard route (/files/*)
└── api.v1.$.tsx           # /api/v1/*

Nested Routes

app/routes/
├── dashboard.tsx              # /dashboard
├── dashboard._index.tsx       # /dashboard
├── dashboard.settings.tsx     # /dashboard/settings
├── dashboard.analytics.tsx    # /dashboard/analytics
└── dashboard.users.tsx        # /dashboard/users

Splats and Optional Segments

tsx
// app/routes/files.$.tsx
export default function Files() {
  return <div>File explorer</div>;
}

// app/routes.docs.$.tsx
// Matches /docs, /docs/react, /docs/react/hooks, etc.

Nested Routing

Nested routes allow you to share layouts and load data hierarchically:

tsx
// app/routes/dashboard.tsx
import { Outlet, Link, useLocation } from '@remix-run/react';

export default function DashboardLayout() {
  return (
    <div className="dashboard">
      <aside>
        <nav>
          <Link to="/dashboard/overview">Overview</Link>
          <Link to="/dashboard/users">Users</Link>
          <Link to="/dashboard/settings">Settings</Link>
        </nav>
      </aside>

      <main>
        <Outlet /> {/* Child routes render here */}
      </main>
    </div>
  );
}
tsx
// app/routes/dashboard.users.tsx
export default function Users() {
  return (
    <div>
      <h2>Users</h2>
      <UserList />
    </div>
  );
}

Route Hierarchy and Data

Each route can have its own loader:

tsx
// app/routes/products.tsx
import { Outlet, useLoaderData } from '@remix-run/react';
import { json } from '@remix-run/node';

export const loader = async () => {
  const categories = await getCategories();
  return json({ categories });
};

export default function Products() {
  const { categories } = useLoaderData<typeof loader>();

  return (
    <div>
      <nav>
        {categories.map(cat => (
          <Link key={cat.id} to={`/products/${cat.slug}`}>
            {cat.name}
          </Link>
        ))}
      </nav>

      <Outlet />
    </div>
  );
}
tsx
// app/routes/products.$category.tsx
export const loader = async ({ params }: LoaderArgs) => {
  const products = await getProductsByCategory(params.category);
  return json({ products });
};

export default function CategoryProducts() {
  const { products } = useLoaderData<typeof loader>();

  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Dynamic Routes

Single Dynamic Segment

tsx
// app/routes/posts.$postId.tsx
import { useLoaderData, useParams } from '@remix-run/react';

export const loader = async ({ params }: LoaderArgs) => {
  const post = await getPost(params.postId);

  if (!post) {
    throw new Response('Not Found', { status: 404 });
  }

  return json(post);
};

export default function Post() {
  const post = useLoaderData<typeof loader>();
  const params = useParams();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Multiple Dynamic Segments

tsx
// app/routes.orgs.$orgId.projects.$projectId.tsx
export const loader = async ({ params }: LoaderArgs) => {
  const { orgId, projectId } = params;

  const project = await getProject(orgId, projectId);
  return json(project);
};

export default function ProjectDetails() {
  const project = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>{project.name}</h1>
      <p>Organization: {project.orgId}</p>
    </div>
  );
}

Optional Segments

tsx
// app/routes.lang-$.tsx
// Matches /lang-en, /lang-es, etc.

export const loader = async ({ params, request }: LoaderArgs) => {
  const lang = params['*']?.replace('lang-', '') || 'en';
  // Use language preference
};

export default function Lang() {
  return <div>Language page</div>;
}

Route Configuration

Custom Route Paths

tsx
// app/routes/admin.tsx
import { redirect } from '@remix-run/node';

export const loader = async () => {
  // Check authentication
  const user = await getUser(request);

  if (!user?.isAdmin) {
    return redirect('/login');
  }

  return json({ user });
};

export default function Admin() {
  return (
    <div>
      <h1>Admin Dashboard</h1>
    </div>
  );
}

Route Meta

tsx
import type { MetaFunction } from '@remix-run/node';

export const meta: MetaFunction = () => {
  return [
    { title: 'My Awesome App' },
    { name: 'description', content: 'Welcome to my app' },
  ];
};

export default function Route() {
  return <div>Hello</div>;
}

Dynamic Meta

tsx
export const meta: MetaFunction<typeof loader> = ({ data }) => {
  return [
    { title: data?.post.title || 'Post' },
    { name: 'description', content: data?.post.excerpt },
  ];
};

The links function defines CSS links:

tsx
import styles from '~/styles/dashboard.css?url';

export function links() {
  return [
    { rel: 'stylesheet', href: styles },
    {
      rel: 'stylesheet',
      href: 'https://cdn.example.com/external.css',
    },
  ];
}

export default function Dashboard() {
  return <div>Dashboard</div>;
}

Route Modules

A complete route module with all exports:

tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { Link, Outlet, useLoaderData } from '@remix-run/react';
import type { MetaFunction, LinksFunction } from '@remix-run/node';
import styles from '~/styles/route.css?url';

// Meta
export const meta: MetaFunction = () => {
  return [{ title: 'My Route' }];
};

// Links
export const links: LinksFunction = () => {
  return [{ rel: 'stylesheet', href: styles }];
};

// Loader
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const data = await fetchData(request);
  return json(data);
};

// Action
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  await processFormData(formData);
  return redirect('/success');
};

// Error Boundary
export function ErrorBoundary() {
  return <div>Something went wrong</div>;
}

// Component
export default function Route() {
  const data = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>My Route</h1>
      <Outlet />
    </div>
  );
}

Parallel Loading

Remix loads nested routes in parallel:

tsx
// app/routes/checkout.tsx
export const loader = async () => {
  const cart = await getCart();
  const user = await getUser();
  return json({ cart, user });
};

// app/routes/checkout.shipping.tsx
export const loader = async () => {
  const shippingMethods = await getShippingMethods();
  return json({ shippingMethods });
};

// app/routes/checkout.payment.tsx
export const loader = async () => {
  const paymentMethods = await getPaymentMethods();
  return json({ paymentMethods });
};

All three loaders run in parallel for maximum performance.

Route Protection

Authentication Required

tsx
// app/routes/dashboard.tsx
import { redirect } from '@remix-run/node';

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const user = await getUser(request);

  if (!user) {
    return redirect('/login');
  }

  return json({ user });
};

export default function Dashboard() {
  const { user } = useLoaderData<typeof loader>();
  return <div>Welcome, {user.name}</div>;
}

Role-Based Access

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const user = await getUser(request);

  if (!user?.roles.includes('admin')) {
    throw new Response('Forbidden', { status: 403 });
  }

  return json({ user });
};

Pagination

tsx
// app/routes/products.tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get('page') || '1');
  const limit = 20;

  const { products, total } = await getProducts({ page, limit });
  const totalPages = Math.ceil(total / limit);

  return json({ products, page, totalPages });
};

export default function Products() {
  const { products, page, totalPages } = useLoaderData<typeof loader>();

  return (
    <div>
      <ProductList products={products} />

      <div className="pagination">
        {page > 1 && (
          <Link to={`?page=${page - 1}`}>Previous</Link>
        )}

        <span>Page {page} of {totalPages}</span>

        {page < totalPages && (
          <Link to={`?page=${page + 1}`}>Next</Link>
        )}
      </div>
    </div>
  );
}

Conclusion

Remix's routing system provides a powerful, intuitive way to manage application structure. By understanding file conventions, nested routes, and route modules, you can build complex applications with clean, maintainable code.

The key is to think in terms of layouts and data hierarchy. Each route can load its own data, define its own meta, and handle its own errors. This leads to better code organization and improved performance through parallel data loading.

Enjoyed this essay? Share it.

Related essays

Next essay
Code Review Best Practices: Guidelines and Checklists