Skip to main content

Chapter 19: Code Splitting & Lazy Loading

Code splitting reduces your application's initial bundle size by loading code only when needed. TanStack Router's file-based routing provides automatic route-based code splitting.

Route-Based Code Splitting​

TanStack Router supports lazy loading route components, loaders, and other route configuration:

// src/routes/_authenticated/projects/$projectId.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/projects/$projectId")({
// The component is loaded lazily
component: () => import("@/features/projects/components/project-detail").then(
(m) => m.ProjectDetail
),
});

Automatic with .lazy.tsx Convention​

TanStack Router supports a .lazy.tsx convention for automatic code splitting:

src/routes/_authenticated/projects/
├── $projectId.tsx # Route config (params, search, loader)
└── $projectId.lazy.tsx # Component (lazy loaded)
// $projectId.tsx — always loaded (small)
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/projects/$projectId")({
loader: ({ params, context }) =>
context.queryClient.ensureQueryData(projectQueryOptions(params.projectId)),
});

// $projectId.lazy.tsx — lazy loaded (can be large)
import { createLazyFileRoute } from "@tanstack/react-router";
import { ProjectDetail } from "@/features/projects";

export const Route = createLazyFileRoute("/_authenticated/projects/$projectId")({
component: ProjectDetail,
pendingComponent: ProjectDetailSkeleton,
errorComponent: ProjectDetailError,
});

The route configuration (loader, search validation, beforeLoad) is loaded immediately because it is needed for prefetching. The component, pendingComponent, and errorComponent are loaded lazily because they are only needed when the route renders.

React.lazy for Non-Route Components​

For heavy components that are not routes (large charts, rich text editors, code editors):

import { lazy, Suspense } from "react";

// Lazy load heavy components
const RichTextEditor = lazy(() => import("@/shared/components/rich-text-editor"));
const Chart = lazy(() => import("@/shared/components/chart"));

function TaskDetail({ task }: { task: Task }) {
return (
<div>
<h1>{task.name}</h1>

{/* Lazy-loaded rich text editor */}
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<RichTextEditor
value={task.description}
onChange={handleDescriptionChange}
/>
</Suspense>

{/* Lazy-loaded chart */}
{task.analytics && (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<Chart data={task.analytics} />
</Suspense>
)}
</div>
);
}

Preloading​

On Intent (Hover/Focus)​

TanStack Router supports preloading routes when the user hovers over or focuses on a link:

// Router configuration
export const router = createRouter({
routeTree,
defaultPreload: "intent", // Preload on hover/focus
defaultPreloadStaleTime: 0, // Always preload (don't consider data fresh)
});

With this configuration, when a user hovers over a <Link>, TanStack Router:

  1. Loads the lazy route component
  2. Runs the route loader
  3. Caches the data

By the time they click, everything is ready — instant navigation.

Manual Preloading​

const router = useRouter();

// Preload a specific route programmatically
function UpcomingProjectsList({ projects }: { projects: Project[] }) {
return (
<ul>
{projects.map((project) => (
<li
key={project.id}
// Preload when the list item becomes visible
onMouseEnter={() => {
router.preloadRoute({
to: "/projects/$projectId",
params: { projectId: project.id },
});
}}
>
<Link to="/projects/$projectId" params={{ projectId: project.id }}>
{project.name}
</Link>
</li>
))}
</ul>
);
}

Vite Chunk Splitting Strategy​

Configure Vite to split vendor libraries into separate, cacheable chunks:

// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// Vendor chunks — cached across deployments
if (id.includes("node_modules")) {
if (id.includes("react") || id.includes("react-dom")) {
return "react-vendor";
}
if (id.includes("@tanstack")) {
return "tanstack-vendor";
}
if (id.includes("effect")) {
return "effect-vendor";
}
if (id.includes("@radix-ui")) {
return "radix-vendor";
}
}
},
},
},
},
});

Analyzing Bundle Size​

# Install the analyzer
pnpm add -D rollup-plugin-visualizer

# Add to vite.config.ts
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
plugins: [
// ... other plugins
visualizer({
filename: "dist/stats.html",
gzipSize: true,
brotliSize: true,
}),
],
});

# Build and open the report
pnpm build
open dist/stats.html

The visualizer produces a treemap showing exactly what is in each chunk, how much space it takes, and where optimization opportunities exist.

Loading States​

Route-Level Pending Components​

export const Route = createFileRoute("/_authenticated/dashboard")({
pendingComponent: DashboardSkeleton,
pendingMs: 200, // Only show after 200ms to avoid flash
pendingMinMs: 500, // Show for at least 500ms to avoid jank
component: Dashboard,
});

function DashboardSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-64" />
</div>
);
}

The pendingMs and pendingMinMs settings prevent:

  • Flash of loading state — if data loads in under 200ms, no skeleton is shown
  • Flash of content — if skeleton appears, it stays for at least 500ms to avoid a jarring transition

Summary​

  • ✅ Route-based code splitting via .lazy.tsx convention for automatic splitting
  • ✅ React.lazy for heavy non-route components (editors, charts)
  • ✅ Preloading on intent loads data before the user clicks
  • ✅ Manual chunk splitting in Vite for optimal caching
  • ✅ Bundle analysis with rollup-plugin-visualizer
  • ✅ Loading states with pendingMs and pendingMinMs to prevent flash