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:
- Centralized Fragility: All layout logic is in one place. Adding a new layout requires modifying this central file.
- Not Co-located: The layout logic is completely detached from the routes it governs.
- Poor Scalability: As you add more layouts (e.g., admin, settings, marketing), this file becomes an unmanageable
if/else
mess. - 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 thedashboard
route. - It sees that
dashboard
is a child of theroot
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 (
);
}
);
Button.displayName = 'Button';
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