Tailwind CSS v4 and layout routes in a Vite + React app one app shell, many pages
Put shared UI in a layout route that stays mounted, and let the build generate only the CSS your markup names.
Versions used: vite 8.3.5, @vitejs/plugin-react 6.1.2, react and react-dom 19.3.1, tailwindcss and @tailwindcss/vite 4.3.3, @tanstack/react-router 1.170.45, @tanstack/router-plugin 1.168.47, typescript 6.0.3, oxlint 1.89.0, Node 26.8.2, and Bun 1.4.2 as the package installer only (bun install). Every behavior below was checked in headless Chrome driven by playwright-core 1.64.1, against the production build served by vite preview unless a section says otherwise.
The problem
A team ships a React app with a header, a sidebar, and a dozen pages. Each page component renders its own copy of the header and the sidebar. The cost shows up later. A user types "tls" into the sidebar's filter box and clicks another page, and the box is empty again. The new page created a brand new sidebar, and the old one, along with its typed text, was thrown away. The "current page" highlight is worse. Every page hard-codes which link looks selected, so adding a page means editing the highlight in several places, and someone always forgets one.
The stylesheet has a similar story. It began as a small global CSS file and grew with every feature. It now holds hundreds of class names, and nobody dares delete one, because any file in the project might use a global class and no tool says which ones do. The file only grows.
Both problems are about structure. The first is about where shared UI lives: it belongs in one layout that wraps the pages, not inside each page. The second is about where styles live: they belong next to the markup that uses them, and the final CSS should be generated from what the markup contains. A small Vite + React notes app fixes both: Tailwind CSS v4 generates the styles from the markup, and TanStack Router layout routes own the shared UI.
The idea in plain words
Put everything the pages share into one parent component that stays on screen while only the middle of the page changes, and write styles as short class names directly in the markup so a build step can generate exactly the CSS those names need. TanStack Router supplies the parent through a layout route, whose Outlet marks the spot where each page appears. Tailwind supplies the styling through a scanner that reads source files as text, so a class that no file mentions never reaches the stylesheet and a class that does appear is easy to find.
Concept A: Tailwind CSS v4 in Vite
What it is
Tailwind CSS is a styling tool built from utility classes. A utility class is a small CSS class that sets one thing, such as px-3 for horizontal padding or text-sm for a small font size, and you combine several of them in a className attribute. Instead of writing a rule in a stylesheet and naming it, you compose the look from existing pieces.
The tool does not ship a giant prebuilt stylesheet. According to the Tailwind documentation, it "works by scanning all of your HTML files, JavaScript components, and any other templates for class names, generating the corresponding styles and then writing them to a static CSS file." The part that reads your files is called the scanner.
Why it works this way
Global CSS fails because the link between a class definition and its users is invisible. The scanner reverses the direction: the markup is the source of truth, and the stylesheet is generated from it. When a component is deleted, its classes stop appearing in any file, so the next build drops their rules automatically. Nobody has to audit the stylesheet, because the generated utilities cannot include a class the code does not mention.
Worked example: wiring it up
The Vite setup has three steps from the documentation: install tailwindcss and @tailwindcss/vite, add tailwindcss() to the Vite plugins, and add @import "tailwindcss"; to a CSS file. The notes app's package.json shows the full dependency list, including the router and the type packages.
{
"name": "notes-shell",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "oxlint src"
},
"dependencies": {
"@tanstack/react-router": "1.170.45",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@tanstack/router-plugin": "1.168.47",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"oxlint": "1.89.0",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"vite": "8.3.5"
}
}
This file pins exact versions with no caret ranges, so a fresh bun install fetches the same packages every time. Tailwind itself is split in two: tailwindcss holds the engine and @tailwindcss/vite holds the Vite integration. The router plugin sits in devDependencies because it only runs at build time, while @tanstack/react-router is a runtime dependency. The scripts run Vite, the TypeScript checker and the oxlint linter.
The plugins are then listed in the Vite configuration:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
// The router plugin must come before the React plugin.
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
})
Three plugins run here. The router plugin generates route files and must come first, because the TanStack documentation says to pass @tanstack/router-plugin before @vitejs/plugin-react. The react() plugin handles JSX, and tailwindcss() adds the scanner and CSS generator. The option autoCodeSplitting: true asks the router plugin to split route code into separate chunks, and the build output further down shows the effect: one small JavaScript file per route. Tailwind's position is not order-sensitive in this app: moving tailwindcss() to the front of the list produced a stylesheet with the same SHA-256 hash as the original, byte for byte.
The CSS entry file is short, and it also defines the app's brand colors and its dark mode rule:
@import "tailwindcss";
/* Design tokens: each variable creates utilities (bg-brand-600, text-brand-700, ...). */
@theme {
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-600: oklch(0.55 0.2 260);
--color-brand-700: oklch(0.49 0.2 262);
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
/* Dark mode follows a class on <html> instead of the OS setting. */
@custom-variant dark (&:where(.dark, .dark *));
The first line pulls in Tailwind's base styles, its default theme, and the utility generator. The @theme block holds theme variables, which are special CSS variables that decide which utility classes exist. Each name starts with a namespace: --color-brand-600 is in the --color-* namespace, so Tailwind creates bg-brand-600, text-brand-600, border-brand-600, and the rest. The --font-sans variable in the --font-* namespace redefines the font-sans utility. The values use oklch, a CSS color notation that describes a color by lightness, chroma (color intensity), and hue angle. Variables like these are called design tokens: named values that give the whole app one shared palette. The docs require theme variables to sit at the top level, not nested under selectors or media queries. The last line is covered below.
Evidence from the built CSS
Running vite build on the app produced one CSS file of 11.02 kB (3.07 kB gzipped). These excerpts, one per line, are copied from that minified file:
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
--color-brand-50:oklch(97% .02 250);--color-brand-600:oklch(55% .2 260);--color-brand-700:oklch(49% .2 262)
.data-\[status\=active\]\:bg-brand-600[data-status=active]{background-color:var(--color-brand-600)}
.dark\:bg-gray-950:where(.dark,.dark *){background-color:var(--color-gray-950)}
@media (hover:hover){.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}
The first line is the license banner with the Tailwind version. The second comes from the theme layer, where the minifier rewrote 0.97 as 97%, which is the same lightness. Each brand variable is emitted as a regular CSS variable, and the generated rules read it back with var(--color-brand-600), so changing a token in one place recolors every utility built on it. The remaining lines are generated rules for variants. A variant is a prefix such as hover: or dark: that makes a utility apply only under a condition; these lines show a data-attribute variant, the dark variant and two hover variants, explained further below. Each selector is the class name with special characters escaped by backslashes, which is why data-[status=active]:bg-brand-600 turns into .data-\[status\=active\]\:bg-brand-600. Both hover rules sit inside one @media (hover:hover) block.
The same file proves the "only what is used" claim. The class bg-green-100 appears once and bg-amber-100 appears once, because the badge component uses them. The string .bg-red-500 appears zero times and --color-red-500 appears zero times. Tailwind ships a full palette, yet unused colors are absent, since the docs state that "by default only used CSS variables will be generated in the final CSS output."
The dark variant
By default, the dark: variant follows the operating system's prefers-color-scheme setting. The line @custom-variant dark (&:where(.dark, .dark *)); replaces that with a class check: dark: utilities apply whenever a dark class exists on the element or any ancestor. The app's Theme button toggles that class on <html>. The browser probe measured the result: the page background was rgb(255, 255, 255) before the toggle, and afterward the <html> class was dark with a background of oklch(0.13 0.028 261.692).
Keeping class names scannable
The scanner finds names by reading text, so code must contain whole names. The badge component follows that rule:
// Complete class names, so Tailwind's scanner can find every one of them.
const toneClasses = {
ok: 'bg-green-100 text-green-800',
warn: 'bg-amber-100 text-amber-800',
} as const
export function Badge({ tone }: { tone: keyof typeof toneClasses }) {
return <span className={`rounded-full px-2 py-0.5 text-xs ${toneClasses[tone]}`}>{tone}</span>
}
Every color class is spelled out in full inside a plain string, and the component picks one by key at runtime. That follows the documentation's advice to "map props to complete class names that are statically detectable at build-time." The template literal around toneClasses[tone] is harmless, since the scanner finds the finished names in the object above it. The scanner reads every file except those ignored by .gitignore, node_modules, binary files, CSS files, and lock files. Extra paths can be added with @source, and @source inline() safelists names that never appear in text.
Pitfall: building class names dynamically
Symptom
The build succeeds, but badges render with no background. In the browser probe, both badges reported a transparent background even though their class lists looked right:
== 1. dynamic class name (bg-${tone}-100) ==
build exit 0
[browser console] workspace layout mounted
rounded-full px-2 py-0.5 text-xs bg-green-100 text-green-800 -> rgba(0, 0, 0, 0)
rounded-full px-2 py-0.5 text-xs bg-amber-100 text-amber-800 -> rgba(0, 0, 0, 0)
bg-green-100 rules in built CSS: 0
Read the two halves of that output together. The class lists on the elements are exactly what the developer intended, so the component logic is fine. The stylesheet, however, contains zero rules for bg-green-100. When the markup names a class that the built CSS never defines, the browser silently ignores it, and the element falls back to a transparent background.
Cause
The broken component assembled names from pieces:
const toneColor = { ok: 'green', warn: 'amber' } as const
export function Badge({ tone }: { tone: keyof typeof toneColor }) {
const c = toneColor[tone]
return <span className={`rounded-full px-2 py-0.5 text-xs bg-${c}-100 text-${c}-800`}>{tone}</span>
}
At runtime, the browser sees bg-green-100 on the element, so the class list in the output looks correct. At build time, however, the scanner treats files as plain text and "has no way of understanding string concatenation or interpolation." The only text it can read is bg-${c}-100, which is not a class name, so no rule was written. The text-${c}-800 half fails the same way, so the badges also lose their text colors.
Fix
Keep every class name complete in the source, as in the earlier badge.tsx, and select among complete names at runtime. After that change the built CSS contains one rule for each of the two classes.
Concept B: Mobile-first layout and state variants
What it is
A breakpoint is a viewport width at which the layout is allowed to change. Tailwind defines sm at 40rem (640px), md at 48rem (768px), lg at 64rem, xl at 80rem, and 2xl at 96rem. Writing md: in front of a utility is a variant that wraps the rule in @media (width >= 48rem). Tailwind's approach is mobile-first: unprefixed utilities apply at every width, while prefixed ones apply "at the specified breakpoint and above."
Why it works this way
Designing small screens first means the base styles are the simplest ones, and each breakpoint only adds. The Tailwind docs advise to "implement the mobile layout for a design first, then layer on any changes." Without that order, a desktop-first stylesheet needs overrides that undo desktop rules on phones, and those overrides pile up and conflict.
Other variants work the same way. Every utility can be applied conditionally by putting a variant at the start of its name. Per the docs, hover:bg-sky-700 "only defines styles for the :hover state". The data-[…] form matches a data attribute, so data-[size=large]:p-8 becomes a rule keyed to [data-size=large]. The square brackets are an arbitrary value, meaning a one-off value written inline instead of chosen from the theme. Variants can also be stacked, as in dark:md:hover:bg-fuchsia-600.
Worked example: the app header
TanStack Router's Link component renders a real anchor tag, and it adds a data-status attribute to that tag when the link is active. The attribute is active or absent. A link counts as active when its path is a prefix of the current path. The root route component uses the attribute to style the current page without any per-page code:
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({ component: RootLayout })
const navLink =
'rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 ' +
'data-[status=active]:bg-brand-600 data-[status=active]:text-white ' +
'dark:text-gray-200 dark:hover:bg-gray-800'
function RootLayout() {
return (
<div className="min-h-screen bg-white font-sans text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<span className="mr-auto font-semibold">Notes</span>
<Link to="/" className={navLink} activeOptions={{ exact: true }}>
Home
</Link>
<Link to="/notes" className={navLink}>
Notes
</Link>
<Link to="/settings" className={navLink}>
Settings
</Link>
<button
type="button"
className="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-700"
onClick={() => document.documentElement.classList.toggle('dark')}
>
Theme
</button>
</header>
<Outlet />
</div>
)
}
The navLink constant joins three string literals with +. That is safe, because each class inside is a whole token and the scanner reads them as text. The line data-[status=active]:bg-brand-600 paints the active link with the brand color, and hover:bg-gray-100 handles pointer hover. The hover: variant is wrapped in @media (hover: hover), so it only applies on devices whose primary pointer can hover, such as a mouse or trackpad. The Home link passes activeOptions={{ exact: true }}; without it, / is a prefix of every path and Home would always look active. mr-auto on the title pushes the links to the right. The <Outlet /> at the bottom is explained in the next concept.
The probe loaded /notes at a 1280px viewport and read the results from the live page:
header "Notes" link data-status: active | background: oklch(0.55 0.2 260)
header "Home" link data-status: null
The Notes link carried data-status="active" and a computed background equal to --color-brand-600, so the data-[status=active]:bg-brand-600 rule matched. The Home link had no attribute at all, which the probe reports as null, and so kept its plain style. Nobody wrote highlight logic for either page, since the router sets the attribute and a single class string reacts to it.
Worked example: the workspace grid
The sidebar-plus-content area is a second layout. It is a grid with one column on narrow screens and two on wide ones:
import { useEffect, useState } from 'react'
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_workspace')({ component: WorkspaceLayout })
const sideLink =
'block rounded-md px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-gray-800 ' +
'data-[status=active]:font-semibold data-[status=active]:text-brand-700'
function WorkspaceLayout() {
// Local state in the layout: survives navigation between child routes.
const [filter, setFilter] = useState('')
useEffect(() => console.log('workspace layout mounted'), [])
return (
<div className="grid grid-cols-1 md:grid-cols-[14rem_1fr]">
<aside className="border-b border-gray-200 p-4 md:min-h-[calc(100vh-3.5rem)] md:border-r md:border-b-0 dark:border-gray-800">
<input
aria-label="Filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter…"
className="mb-3 w-full rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-700 dark:bg-gray-900"
/>
<nav className="flex gap-1 md:flex-col">
<Link to="/notes" className={sideLink}>All notes</Link>
<Link to="/settings" className={sideLink}>Settings</Link>
</nav>
</aside>
<section className="p-6">
<Outlet />
</section>
</div>
)
}
The key line is grid grid-cols-1 md:grid-cols-[14rem_1fr]. The unprefixed grid-cols-1 gives a single column everywhere. At 48rem and wider, md:grid-cols-[14rem_1fr] replaces it with a fixed 14rem sidebar column and a flexible second column; the _ inside the brackets stands for a space. Inside the aside, the border classes switch from a bottom edge on phones to a right edge on desktop, and md:min-h-[calc(100vh-3.5rem)] gives the sidebar a minimum height of the viewport minus 3.5rem, roughly the header's height, so its border runs to the bottom of the screen. The nav is a row by default and becomes a column with md:flex-col. The useState and useEffect lines matter for the next concept.
The probe measured both ends of the range:
1280px /notes aside width: 224px | grid-template-columns: 224px 1056px
sidebar "All notes" font-weight: 600 | color: oklch(0.49 0.2 262)
375px grid-template-columns: 375px | nav flex-direction: row
At 1280px wide, 14rem computes to 224px, and the content column takes the remaining 1056px. The sidebar link for the current page is semibold (weight 600) in brand-700, from the same data-status mechanism. After resizing to 375px, the grid collapses to a single 375px column and the sidebar nav is still a row, which shows that the md: rules stop applying below 48rem while the unprefixed ones remain.
Pitfall: reading sm: as "on small screens"
Symptom
A developer writes sm:text-center, expecting centered text on phones, and the text stays left-aligned on a phone. To reproduce it, two paragraphs were added to the home page, one with sm:text-center and one with the corrected text-center sm:text-left, and the probe read the computed text-align at two widths:
375px: sm:text-center -> text-align start | text-center sm:text-left -> text-align center
700px: sm:text-center -> text-align center | text-center sm:text-left -> text-align left
At 375px the sm: paragraph keeps the browser default (start, which is left in a left-to-right page), and it only centers at 700px. The corrected paragraph does the opposite, which is what the developer wanted.
Cause
The name sm is a breakpoint, not a device class. A prefixed utility is a minimum-width condition, so it applies at that width and everything wider. In the docs' words, "Don't think of sm: as meaning 'on small screens', think of it as 'at the small breakpoint'."
Fix
Write the mobile style without a prefix and use the prefix for larger screens. The docs' version is text-center sm:text-left: centered by default, left-aligned from 640px up, as the second probe line shows. The notes list in the app follows this pattern with grid gap-3 sm:grid-cols-2, a single column by default and two columns from 640px.
Concept C: Layout routes and pathless layout routes
What it is
File-based routing is a convention where the files in a routes directory define the app's URLs, so adding a file adds a page. TanStack Router's Vite plugin watches src/routes, the default folder, and writes a generated file, src/routeTree.gen.ts, that describes the result. A route tree is the nested structure of routes, with one root at the top and children below. Per the docs, the root route "has no path," "is always matched," and its component "is always rendered."
A layout route is a route that wraps its child routes with extra components and logic. A pathless layout route is a layout route whose file name starts with _, which tells the router it does not add a URL segment. In the docs' words, such routes "do not require a matching path in the URL," and the text after the _ becomes the route's ID.
The wrapper needs a place to show the child. The Outlet component marks that place, and per the docs it "is used to render the next potentially matching child route." It takes no props, and if no child matches it renders null.
Why it works this way
Nesting gives the shared UI a single owner. The React documentation puts the rule this way: "React preserves a component's state for as long as it's being rendered at its position in the UI tree. If it gets removed, or a different component gets rendered at the same position, React discards its state." A layout rendered once above all of its pages keeps its position while the pages change, so it is mounted once. Mounting means React creates the component and runs its effects, and unmounting means React removes it and discards its state. A sidebar that lives in the layout is not recreated when the user moves between child pages, so its local state, such as the filter text, survives. If each page rendered its own sidebar, every page change would unmount one sidebar and mount another, and the typed text would vanish.
The idea predates this router. Alex Banks and Eve Porcello's Learning React motivates nested routes the same way: "as users navigate our apps, we want some of the UI to stay in place," a need that page templates and master pages once met. Their examples use React Router, which has its own API, and its slot component is also called Outlet.
Worked example: file names become the tree
The app's route files are __root.tsx, index.tsx, _workspace.tsx, _workspace.notes.tsx, and _workspace.settings.tsx. The dot in a file name separates route segments, so _workspace.notes.tsx is the notes child of the _workspace layout. The layout file itself was shown in full in the previous concept. Here are the three page files, starting with the notes page:
import { createFileRoute } from '@tanstack/react-router'
import { Badge } from '../badge'
export const Route = createFileRoute('/_workspace/notes')({ component: Notes })
const notes = [
{ id: 1, title: 'Buy coffee filters', tone: 'ok' as const },
{ id: 2, title: 'Renew the TLS certificate', tone: 'warn' as const },
]
function Notes() {
return (
<>
<h1 className="text-2xl font-bold">All notes</h1>
<ul className="mt-4 grid gap-3 sm:grid-cols-2">
{notes.map((n) => (
<li key={n.id} className="rounded-lg border border-gray-200 p-4 shadow-sm dark:border-gray-800">
<span className="font-medium">{n.title}</span> <Badge tone={n.tone} />
</li>
))}
</ul>
</>
)
}
This page contains no header, no sidebar, and no wrapper, only its own content: a heading and a list of cards. The path string '/_workspace/notes' is the route's ID, including the layout. The docs say the string passed to createFileRoute is written and kept in sync by the router plugin, so renaming the file updates it automatically. The Badge component from the earlier section supplies the colored labels, and sm:grid-cols-2 switches the list to two columns from 640px.
The settings page and the home page complete the set. They are shown together here, one file after the other:
// src/routes/_workspace.settings.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_workspace/settings')({ component: Settings })
function Settings() {
return (
<>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Nothing to configure yet.</p>
</>
)
}
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({ component: Home })
function Home() {
return (
<main className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-bold">Welcome</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Pick Notes or Settings above.</p>
</main>
)
}
The settings file is named _workspace.settings.tsx, so it renders inside the workspace layout and gets the sidebar without mentioning it; the fragment <>...</> lets its heading and paragraph sit directly in the layout's section. The home file has no _workspace prefix, so it is a direct child of the root route and shows only the header, with mx-auto max-w-2xl centering a column capped at 42rem. That makes / a handy test for what happens to the layout when the user leaves it.
The application entry point creates the router from the generated tree:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import './styles.css'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
Two imports matter most: ./routeTree.gen supplies the tree the plugin generated, and ./styles.css pulls in the Tailwind entry file so Vite includes the generated CSS. The declare module block is a TypeScript declaration that tells the router types which router instance this app uses, so that props like to="/notes" are checked against real routes. RouterProvider renders whatever the current URL matches. Per the TanStack docs, the generated file is managed by the router, so it belongs in lint and format ignore lists.
The generated file records exactly what the file names mean:
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspaceRoute = WorkspaceRouteImport.update({
id: '/_workspace',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspaceNotesRoute = WorkspaceNotesRouteImport.update({
id: '/notes',
path: '/notes',
getParentRoute: () => WorkspaceRoute,
} as any)
const WorkspaceSettingsRoute = WorkspaceSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => WorkspaceRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/notes': typeof WorkspaceNotesRoute
'/settings': typeof WorkspaceSettingsRoute
}
Look at WorkspaceRoute: it has an id but no path, and its parent is the root route. That is what "pathless" means in data form. The notes and settings routes have paths /notes and /settings and name WorkspaceRoute as their parent, which wires them into the layout. The final interface maps full URLs to routes, and /notes appears with no _workspace segment, so the _workspace name never shows in the address bar.
Evidence: the layout stays mounted
The probe typed "tls" in the sidebar filter on /notes, clicked the sidebar's Settings link, went to / through the header, and then returned to /notes. Client-side navigation is a page change where JavaScript updates the URL and swaps components, without requesting a new HTML document from the server. The probe ran against the production build. Its output:
[browser console] workspace layout mounted
after client nav to /settings, filter = "tls"
on / the sidebar exists: false
[browser console] workspace layout mounted
back on /notes, filter = ""
The first log line comes from the initial load. Moving from /notes to /settings printed nothing new, and the filter still read "tls", because both pages are children of the same mounted layout. Going to / removed the sidebar from the page, since the home route is not inside _workspace, which unmounted the layout and dropped its state. Returning to /notes mounted a fresh layout (the second log line), and the filter came back as an empty string.
A common misreading comes from running the same check against the development server. With vite dev, loading /notes once prints the mount message twice:
[browser console] workspace layout mounted
[browser console] workspace layout mounted
That is not a remount caused by the router. The entry file wraps the app in <StrictMode>, and React's Strict Mode runs "one extra setup+cleanup cycle in development for every Effect" so that missing cleanup code shows up early. The extra cycle is development-only and does not affect the production build, which is why the preview run above printed the message once. Count mounts against a production build, or count only the messages printed after the first page has settled.
Pitfall 1: a layout without an Outlet
Symptom
The sidebar appears, but the page body is empty. The probe, run against a build where the layout's <Outlet /> was replaced by {null}, printed this:
== 2. layout without <Outlet /> ==
build exit 0
[browser console] workspace layout mounted
/notes has aside: true | h1 count: 0 | section HTML: ""
Cause
The router matched /notes correctly and mounted the layout, but the layout never says where the child goes. The Outlet is the only place a matched child renders, so without it the section element is empty and no h1 exists. The build still succeeds because nothing is wrong at the type level. Learning React describes the same symptom for nested routes in React Router: opening the nested address shows only the parent's content, and the child "doesn't display" until an Outlet is added.
Fix
Render <Outlet /> inside the layout at the position where child pages should appear. In this app that is the section next to the sidebar. If a route's component is left undefined, the router renders an Outlet automatically.
Pitfall 2: a plain anchor instead of Link
Symptom
The filter text vanishes after clicking a sidebar link, and the mount message prints twice. This is the probe output after swapping one sidebar Link for an <a href>:
== 3. plain <a href> instead of <Link> in the sidebar ==
build exit 0
[browser console] workspace layout mounted
[browser console] workspace layout mounted
after <a href> to /settings, filter = ""
The two mount messages are the useful clue. In the working app, moving from /notes to /settings printed nothing new because the layout stayed mounted. Here the second message appears right after the click, so the layout was created again from scratch, and its useState started over with an empty string. Something replaced the whole page rather than just the Outlet.
Cause
A Link renders an anchor tag with a valid href, and the router handles a normal click inside the running app; the single mount message in the earlier run is the evidence. A raw <a href> has no router behind it, so the browser requests a new document from the server. The whole app restarts, and React mounts the layout again, which explains the second log line and the empty filter. Layout state only survives navigation that stays inside the running app.
Fix
Use Link from @tanstack/react-router for in-app navigation. Keep plain anchors for external sites.
Pitfall 3: naming a child without the layout prefix
Symptom
A page that should have the sidebar renders without it. The generated tree shows why when the file _workspace.settings.tsx is renamed to settings.tsx and its path string changed to '/settings'. This is grep -n output from the regenerated routeTree.gen.ts, with line numbers on the left:
26:const SettingsRoute = SettingsRouteImport.update({
27- id: '/settings',
28- path: '/settings',
29- getParentRoute: () => rootRouteImport,
Cause
The file name decides the parent. Without the _workspace. prefix, the route's parent is the root route, as getParentRoute: () => rootRouteImport shows, so the workspace layout is not among its ancestors and never wraps it. The build still exits 0 and the URL is unchanged, which makes the mistake easy to miss.
Fix
Name the file _workspace.settings.tsx and use createFileRoute('/_workspace/settings'), then confirm in the generated tree that the parent is WorkspaceRoute.
Putting it together: the request for /notes
Opening /notes in the browser loads the app's single HTML file and its scripts, and main.tsx calls createRouter with the generated tree. The router matches the URL against the tree and finds a chain of three routes: the root, then the _workspace layout, then notes. It renders them nested, outermost first. The root component draws the header and reaches its <Outlet />, where the workspace layout renders the sidebar, the filter box, and a section with its own <Outlet />. That inner outlet renders the Notes page, with its heading and cards.
Every class in those files was already known to the build. The scanner read __root.tsx, _workspace.tsx, _workspace.notes.tsx, and badge.tsx as text, and the build wrote their rules into a single stylesheet. The final build output shows what shipped:
vite v8.3.5 building client environment for production...
transforming...
✓ 115 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.39 kB │ gzip: 0.26 kB
dist/assets/index-DwDbrPrs.css 11.02 kB │ gzip: 3.07 kB
dist/assets/_workspace.settings-Cje_JMd6.js 0.30 kB │ gzip: 0.23 kB
dist/assets/routes-CMzOexHg.js 0.33 kB │ gzip: 0.24 kB
dist/assets/_workspace.notes-Wadg-xgE.js 0.77 kB │ gzip: 0.44 kB
dist/assets/_workspace-CHkoZs_d.js 1.06 kB │ gzip: 0.58 kB
dist/assets/index-Dk6ROVa3.js 298.36 kB │ gzip: 95.94 kB
✓ built in 121ms
The CSS line is the whole stylesheet: one 11.02 kB file for the entire app, covering header, sidebar, cards, badges, hover states, and dark mode. The JavaScript lines show code splitting at work, with the workspace layout, the notes page, and the settings page each in a file of about a kilobyte or less, and the large entry file holding React, the router and the code every page needs. The type checker (tsc --noEmit) and oxlint src both exited 0 with no findings.
Back to the opening problem: the sidebar's filter now survives page changes because there is one sidebar, owned by one layout route, and moving between /notes and /settings swaps only the Outlet's content. The highlight needs no per-page code because Link marks the active link with data-status and a single class string reacts to it. The stylesheet can no longer grow in secret, since each rule exists only because a file in the project mentions its class, and deleting a component removes its rules on the next build.
Check yourself
- Why does
bg-${c}-100fail to style an element while a lookup object containing'bg-green-100 text-green-800'works? Answer: Tailwind reads source files as plain text and cannot evaluate string interpolation, so it finds only the fragmentbg-${c}-100and writes no rule. A complete class name in a string literal is found by the scanner and generates its CSS. - With
grid grid-cols-1 md:grid-cols-[14rem_1fr], what does the grid look like at 375px and at 1280px? Answer: At 375px onlygrid-cols-1applies, giving one column of the full width. At 1280px themd:rule (48rem and wider) applies, giving a 14rem (224px) sidebar column and a 1056px content column. - What does the leading
_in_workspace.tsxdo, and why does the sidebar filter keep its text when moving from/notesto/settings? Answer: The_prefix makes the route pathless, so it wraps its children without adding a URL segment. Both pages are children of the same layout, which stays mounted while only the Outlet content changes, so its state is kept. - A layout route shows its sidebar but the page body is blank, and no heading exists in the DOM. What is the most likely cause? Answer: The layout component is missing an
<Outlet />. The Outlet is where the matched child route renders, so without it the child never appears. - Why does a plain
<a href="/settings">empty the filter box while aLinkdoes not? Answer: A plain anchor makes the browser load a new document, which restarts the app and mounts the layout again with empty state.Linknavigates inside the running app, so the layout remains mounted.
Sources
- Tailwind CSS: Installing with Vite
- Tailwind CSS: Detecting classes in source files
- Tailwind CSS: Theme variables
- Tailwind CSS: Responsive design
- Tailwind CSS: Hover, focus, and other states
- Tailwind CSS: Dark mode
- TanStack Router: Installation with Vite
- TanStack Router: Routing Concepts
- TanStack Router: Outlets
- TanStack Router: Navigation
- React: Preserving and Resetting State
- React: StrictMode
- Alex Banks and Eve Porcello, Learning React, 2nd ed. (O'Reilly), ch. 11 React Router, "Nesting Routes", pp. 261-264