Performance
Optimize bundle sizes, code splitting, and loading performance in VitNode and TanStack Start.
TanStack Start splits components into separate lazy chunks by default, keeping the initial entry bundle light. Follow these key practices to maintain instant page loads.
Performance Checklist
- Keep
headmetadata isolated: Never import UI components into routehead. - Lazy-load heavy dialogs: Use
React.lazy+Suspensefor complex editors and dialogs. - Dynamic imports inside loaders: Lazy-load heavy server/client utilities with
await import(...). - Optimize images: Use
loading="lazy", responsive widths, and explicit dimensions. - Analyze your bundle: Regularly check client chunk sizes.
What Lands in the Client Entry Bundle
TanStack Router automatically extracts route component, errorComponent, and notFoundComponent into lazy chunks:
| Route Option | Read When | In Initial Bundle? | Optimization |
|---|---|---|---|
path, id | Route tree generation | Yes | Keep route paths concise |
head | Page navigation | Yes | Isolate metadata strings in leaf files |
loader | Pre-render | Yes (fn only) | await import() large dependencies inside fn |
pendingComponent | Route loading | Yes | Plain skeleton markup only - never a layout component from a UI library |
component | Render | No (Lazy Chunk) | Automatically code-split |
Best Practices
1. Isolate Metadata from Components
Because head is evaluated in the main route bundle, importing strings from component files accidentally pulls entire component trees into the initial download:
import { definePluginRoute } from '@vitnode/core/routing'
// BAD: Pulls HomeRouteContent and all its heavy icons/charts into main entry
import { HOME_TITLE, HomeRouteContent } from '../views/home-content'
// GOOD: Metadata strings live in a lightweight leaf file
import { HomeRouteContent } from '../views/home-content'
import { HOME_DESCRIPTION, HOME_TITLE } from '../views/metadata'
export const route = definePluginRoute({
head: () =>
pageHead({
title: HOME_TITLE,
description: HOME_DESCRIPTION,
}),
})
export default HomeRouteContent2. Lazy Dialogs and Heavy Form Editors
Heavy editors (like Tiptap) or complex modals should be lazy-loaded with React.lazy and Suspense:
import React, { Suspense } from 'react'
import { Spinner } from '@vitnode/core/components/ui/spinner'
const RichEditor = React.lazy(async () =>
import('@vitnode/core/components/form/fields/editor').then((mod) => ({
default: mod.AutoFormEditor,
})),
)
export const ArticleEditor = (props) => (
<Suspense fallback={<Spinner size="xl" />}>
<RichEditor {...props} />
</Suspense>
)3. Dynamic Imports Inside Loaders
When a route loader requires a heavy calculation or parsing library, import it dynamically:
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
load: async () => {
// Only downloaded when visitor navigates to /stats
const { calculateStats } = await import('../features/stats/calculator')
return calculateStats()
},
})4. Lazy Images Below the Fold
Always specify explicit aspect ratios and loading="lazy" on images outside the initial viewport, and ship WebP with a srcSet so a phone never downloads the desktop rendition:
<img
src={photo.large}
srcSet={`${photo.small} 800w, ${photo.large} 1600w`}
sizes="(min-width: 1024px) 1024px, 100vw"
alt="Community event"
width={1600}
height={866}
loading="lazy"
decoding="async"
className="aspect-video object-cover rounded-lg"
/>The one image that is the page's largest element on arrival - a hero screenshot - is the exception: give it loading="eager" and fetchPriority="high" so the browser asks for it first.
5. Skip Rendering Work for Offscreen Sections
A long marketing page can carry a hundred CSS animations, most of them below the fold. Give each section content-visibility: auto with a contain-intrinsic-size estimate and the browser skips style, layout and animation work for everything not near the viewport:
.mk-section-anchor {
scroll-margin-top: 5rem;
content-visibility: auto;
contain-intrinsic-size: auto 48rem;
}The home page went from 133 running animations to 42 on arrival with this one rule, and the pages still scroll and deep-link normally.
6. Keep the Plugin Factory Light
vitnode.config.ts is bundled with the document shell, so everything a plugin factory imports ships to every visitor. A factory that spreads admin/content or admin/nav drags the AdminCP editing stack - AutoForm, schemas, dialogs - into the public bundle. Keep it to pluginId, messages and routes; see Plugin frontend modules.
7. Route-Scoped Stylesheets
Tailwind emits one utility per class it finds in the sources you list. A documentation UI kit scanned globally pays its CSS on the home page too. Give it a stylesheet of its own and load it from the layout route that renders it:
@import '../styles.css';
@import 'fumadocs-ui/css/shadcn.css';
@import 'fumadocs-ui/css/preset.css';
@source '../../node_modules/fumadocs-ui/dist/**/*.js';import docsCss from '@/docs/docs.css?url'
export const Route = createFileRoute('/_docs')({
loader: async () => ({ pageTree: await getDocsPageTree() }),
head: () => ({ links: [{ href: docsCss, rel: 'stylesheet' }] }),
// ...
})A stylesheet a route puts in head is a resource React never unloads, so the moment someone opens the docs the page carries both sheets for the rest of the visit - including after they navigate back to the home page. That is fine, on one condition: the route stylesheet has to scan everything the app stylesheet scans.
Both sheets are full Tailwind builds emitting into the same theme, base, components, utilities layers, and inside one layer the later sheet wins. Let the route stylesheet miss a class and the app stylesheet's version of it loses to whatever the route stylesheet emits last - a missing sm:block loses to a plain hidden, and the site logo and main navigation quietly vanish from every page after a trip to the docs.
Two things keep the sets aligned: the route stylesheet starts by importing the app one, and vitNodeTailwindSources gives both the same @source directives for @vitnode/core and your configured plugins. Anything you add to the app stylesheet's sources, add for the route stylesheet too.
Plain CSS with no Tailwind directives is the easy case. The marketing keyframes in marketing.css use only mk- class names, so the _main layout links the file as-is and admin and docs never download it:
import marketingCss from '@/site/marketing/marketing.css?url'
export const Route = createFileRoute('/_main')({
head: () => ({ links: [{ href: marketingCss, rel: 'stylesheet' }] }),
// ...
})8. Group Shared Vendor Modules
Rolldown splits every module shared by two routes into its own file, which for an icon library means dozens of 300-byte requests per page. Group the libraries that are shared by design into one chunk each:
build: {
rolldownOptions: {
output: {
advancedChunks: {
groups: [
{ name: 'icons', test: /node_modules[\\/].*lucide-react/, minShareCount: 2 },
],
},
},
},
},Group only libraries whose shared surface is small. A UI primitive library shared by every AdminCP screen turns into a single 280 KB chunk that public pages then download for a tooltip.
9. Load JavaScript After the First Paint
TanStack's HeadContent puts a modulepreload link for every chunk the page needs into <head>, ahead of the stylesheet. On a slow phone those downloads compete with the CSS the browser has to wait for, so the first paint arrives late. Render the document with DocumentHead and DocumentScripts from @vitnode/core/tanstack/layout instead:
import {
DocumentHead,
DocumentScripts,
VitNodeRootProviders,
} from '@vitnode/core/tanstack/layout'
<html lang={locale} suppressHydrationWarning>
<head>
<HeadContent />
<DocumentHead />
</head>
<body>
{children}
<Scripts />
<DocumentScripts />
</body>
</html>In a production build the HTML and CSS load first, and a small inline script adds the preloads and the app entry right after the browser paints. In a hidden tab it adds them immediately. Hydration starts a frame or two later than before. During vite dev both components render the stock HeadContent and Scripts. New apps from create-vitnode-app already use them.
Measuring Bundle Size
Analyze your client bundle with Vite:
bun run buildInspect the output chunk sizes in apps/web/.output/public/assets/, then load a page in the browser's network panel and check what the document shell actually requests - a chunk the manifest never preloads can still arrive through a static import. Keep any single lazy chunk below 150 KB for optimal mobile performance.