Mastering Route-Centric Layouts with TanStack Router

In the world of modern web development, the complexity of our React applications often hinges on one critical piece: routing. How we handle different page layouts—like a main app layout with a sidebar, a simple public layout for login pages, or a full-screen marketing layout—can make or break our codebase's maintainability.

For years, we've wrestled with conditional rendering in a top-level App component. We'd inspect location.pathname and play a game of "which layout do I render?", leading to brittle, non-colocated logic.

Today, we're going to build a production-grade application that solves this problem elegantly using TanStack Router. We'll leverage its powerful, type-safe, route-centric approach to create clean, scalable layouts.

Our Tech Stack:

  • Framework: React with Vite
  • Language: TypeScript
  • Routing: TanStack Router (v1)
  • Component Development: Storybook
  • Testing: Vitest & React Testing Library
  • Styling: Vanilla CSS (No Tailwind CSS)

By the end of this post, you'll have a robust foundation and a clear mental model for building complex, layout-driven React applications.

The Problem: The Layout Conundrum

Let's quickly visualize the "old way." You might have a component that looks something like this:

// src/App.tsx - The "Old Way" 👎
import { useLocation } from 'react-router-dom';
import AuthLayout from './layouts/AuthLayout';
import DashboardLayout from './layouts/DashboardLayout';
import PublicPages from './PublicPages';
import AuthenticatedPages from './AuthenticatedPages';

function App() {
  const location = useLocation();
  const isAuthRoute = location.pathname.startsWith('/dashboard');

  if (isAuthRoute) {
    return (
      
        
      
    );
  }

  return (
    
      
    
  );
}

This approach has several critical flaws:

  1. Centralized Fragility: All layout logic is in one place. Adding a new layout requires modifying this central file.
  2. Not Co-located: The layout logic is completely detached from the routes it governs.
  3. Poor Scalability: As you add more layouts (e.g., admin, settings, marketing), this file becomes an unmanageable if/else mess.
  4. No Type Safety: You're relying on string matching (startsWith) which is error-prone.

The Solution: Route-Centric Layouts

TanStack Router flips this model on its head. Instead of a component deciding which layout to render, the route definition itself declares its parent layout.

Layouts are just regular components that render an . The routing structure dictates how they nest.

Mental Model:

  • A request comes in for /dashboard/settings.
  • The router finds the route definition for settings.
  • It sees that settings is a child of the dashboard route.
  • It sees that dashboard is a child of the root route.
  • It renders the component tree: -> -> .

This is clean, declarative, and infinitely scalable.

Let's Build It: Project Setup

First, let's scaffold our project and install dependencies.

# 1. Create a new Vite project
npm create vite@latest tanstack-layouts-app -- --template react-ts
cd tanstack-layouts-app

# 2. Install TanStack Router and its Vite plugin
npm i @tanstack/react-router@beta
npm i -D @tanstack/router-vite-plugin

# 3. Install Storybook
npx storybook@latest init

# 4. Install Testing Libraries
npm i -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom

Step 1: The Root of All Routes (__root.tsx)

This is a special file. It defines the root of your entire application's component tree. Every single route will be rendered inside its .

// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'

export const Route = createRootRoute({
  component: RootComponent,
})

function RootComponent() {
  return (
    <>
      
      
Home (Public) Login Dashboard Settings

{/* Child routes are rendered here */}
) }

Step 2: Defining Our Layout Routes

Layout routes are routes that don't add a path segment but provide a shared UI for their children. By convention, we prefix their filenames with an underscore (_).

The Public Layout

This is for pages like Login or Sign Up. We'll define its styles in a separate CSS file (e.g., src/layouts/PublicLayout.css) and import it.

/* src/layouts/PublicLayout.css */
.public-layout {
  min-height: 100vh;
  background-color: #f1f5f9; /* slate-100 */
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.public-layout-card {
  max-width: 420px;
  width: 100%;
  background-color: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}

.public-layout-card h2 {
  text-align: center;
  font-size: 1.5rem;
  font-weight: bold;
  margin-top: 0;
  margin-bottom: 1.5rem;
}
// src/routes/_public.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
import './layouts/PublicLayout.css'; // Import the styles

export const Route = createFileRoute('/_public')({
  component: PublicLayout,
})

function PublicLayout() {
  return (
    

Welcome!

) }

The Dashboard Layout

This is our main application layout, complete with a sidebar.

/* src/layouts/DashboardLayout.css */
.dashboard-layout {
  display: flex;
  height: 100vh;
  background-color: #f1f5f9; /* slate-100 */
}

.sidebar {
  width: 250px;
  background-color: #1e293b; /* slate-800 */
  color: white;
  padding: 1rem;
  flex-shrink: 0;
}

.sidebar h1 {
  font-size: 1.25rem;
  font-weight: bold;
  margin-bottom: 1rem;
  color: white;
}

.sidebar nav {
  display: flex;
  flex-direction: column;
}

.sidebar nav a {
  padding: 0.75rem 1rem;
  border-radius: 6px;
  color: #e2e8f0; /* slate-200 */
  text-decoration: none;
  transition: background-color 0.2s;
}

.sidebar nav a:hover {
  background-color: #334155; /* slate-700 */
}

.sidebar nav a.active {
  background-color: #0ea5e9; /* sky-500 */
  color: white;
  font-weight: 500;
}

.main-content {
  flex-grow: 1;
  padding: 1.5rem;
  overflow-y: auto;
}
// src/layouts/DashboardLayout.tsx
import { Link, Outlet } from '@tanstack/react-router'
import React from 'react'
import './DashboardLayout.css' // Import styles

export const DashboardLayout: React.FC = () => {
  return (
    
) }

Now, we create the layout route file that uses it.

// src/routes/_dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { DashboardLayout } from '../layouts/DashboardLayout'

export const Route = createFileRoute('/_dashboard')({
  component: DashboardLayout,
})

Notice how clean this is. The file _dashboard.tsx simply states: "any route nested under me will use the DashboardLayout".

Step 3: Creating the Page Routes

Now we place our actual page components inside the directories corresponding to their parent layouts.

Login Page (Public)

// src/routes/_public/login.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_public/login')({
  component: LoginPage,
})

function LoginPage() {
  return (
    

Login

{/* ... login form ... */}

This page uses the public layout.

) }

Dashboard Home Page (Dashboard)

// src/routes/_dashboard/index.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_dashboard/')({
  component: DashboardHomePage,
})

function DashboardHomePage() {
  return (
    

Dashboard

Welcome to the main dashboard! You are inside the dashboard layout.

) }

Step 4: Wire Up the Router in main.tsx

The last step is to create the router instance and provide it to our app.

// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import './index.css' // Your global styles

// Import the generated route tree
import { routeTree } from './routeTree.gen'

// Create a new router instance
const router = createRouter({ routeTree })

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

// Render the app
const rootElement = document.getElementById('root')!
if (!rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(
    
      
    ,
  )
}

Now run npm run dev. Navigate to /, /login, /dashboard, and /dashboard/settings. You'll see the layouts change automatically based on the URL, with the logic perfectly co-located with the routes themselves!

Component Development with Storybook

Production-grade apps require isolated component development. Let's create a story for a simple Button component styled with CSS classes.

1. Create the component and its CSS:

/* src/components/ui/Button.css */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  font-size: 0.875rem; /* 14px */
  font-weight: 500;
  padding: 0.5rem 1rem;
  border: 1px solid transparent;
  cursor: pointer;
  transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}

.btn:focus {
  outline: 2px solid #38bdf8;
  outline-offset: 2px;
}

/* Default Variant */
.btn-default {
  background-color: #0f172a; /* primary-color */
  color: white;
}
.btn-default:hover {
  background-color: #334155; /* secondary-color */
}

/* Destructive Variant */
.btn-destructive {
  background-color: #dc2626; /* destructive-color */
  color: white;
}
.btn-destructive:hover {
  background-color: #b91c1c; /* red-700 */
}
// src/components/ui/Button.tsx
import React from 'react';
import './Button.css'; // Import the styles

type ButtonVariant = 'default' | 'destructive';

export interface ButtonProps extends React.ButtonHTMLAttributes {
  variant?: ButtonVariant;
}

export const Button = React.forwardRef(
  ({ className, variant = 'default', ...props }, ref) => {
    const variantClass = `btn-${variant}`;
    // Combine base class, variant class, and any other passed classes
    const combinedClassName = ['btn', variantClass, className].filter(Boolean).join(' ');

    return (
      

2. Create the story:

// src/components/ui/button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './button';

// To make CSS work in Storybook, import it in your .storybook/preview.ts
// import '../src/components/ui/Button.css';

const meta: Meta = {
  title: 'UI/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'radio' },
      options: ['default', 'destructive'],
    },
  },
};

export default meta;
type Story = StoryObj;

export const Default: Story = {
  args: {
    children: 'Click Me',
    variant: 'default',
  },
};

export const Destructive: Story = {
  args: {
    ...Default.args,
    variant: 'destructive',
    children: 'Delete',
  },
};

Run npm run storybook to see your isolated, documented, and interactive button component.

Testing Our Application

A production app is a tested app. Let's add tests with Vitest and React Testing Library.

Testing a Component that uses the Router (Link)

Our DashboardLayout uses , which needs a router context. We can create a test helper to provide a memory router.

// src/test/test-utils.tsx
import React from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'

export const renderWithRouter = (
  ui: React.ReactElement,
  options: { route?: string } = {},
  renderOptions?: RenderOptions
) => {
  const memoryHistory = createMemoryHistory({
    initialEntries: [options.route || '/'],
  });

  const router = createRouter({
    routeTree,
    history: memoryHistory,
  })

  // Wait for the router to be ready before rendering
  router.hydrate()

  const Wrapper = ({ children }: { children: React.ReactNode }) => (
    {children}
  );

  return render(ui, { wrapper: Wrapper, ...renderOptions })
}

// src/layouts/DashboardLayout.test.tsx
import { screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { DashboardLayout } from './DashboardLayout';
import { renderWithRouter } from '../test/test-utils';

describe('DashboardLayout', () => {
  it('renders the sidebar and an outlet for child content', () => {
    renderWithRouter();

    expect(screen.getByRole('heading', { name: /myapp/i })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /dashboard home/i })).toBeInTheDocument();
  });

  it('sets the active class on the correct link', async () => {
    renderWithRouter(, { route: '/dashboard/settings' });
    
    // The active class is applied asynchronously by the router
    const settingsLink = await screen.findByRole('link', { name: /settings/i });
    const homeLink = screen.getByRole('link', { name: /dashboard home/i });

    expect(settingsLink).toHaveClass('active');
    expect(homeLink).not.toHaveClass('active');
  });
});

Run npm run test or npm run test:ui to see your tests pass.

Conclusion

We've successfully built the foundation for a production-grade React application. By embracing TanStack Router's route-centric layout philosophy, we have achieved:

  • Scalability: Adding new layouts and routes is as simple as adding new files. No more central logic file.
  • Maintainability: Layout logic is co-located with the routes it affects, making the codebase intuitive and easy to reason about.
  • Type Safety: TanStack Router provides end-to-end type safety, from creating links () to accessing route params, eliminating a whole class of runtime errors.
  • Excellent DX: Features like file-based routing, the Vite plugin, and the Devtools create a smooth and efficient development workflow.

This architecture frees you from the layout boilerplate and lets you focus on what truly matters: building great features for your users. The combination of TanStack Router, TypeScript, and a solid testing/component strategy is a powerful recipe for success in any complex React project.

Comments

Popular posts from this blog

Setting up a global .gitignore on a Mac

API Security Best Practices