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

  1. Keep head metadata isolated: Never import UI components into route head.
  2. Lazy-load heavy dialogs: Use React.lazy + Suspense for complex editors and dialogs.
  3. Dynamic imports inside loaders: Lazy-load heavy server/client utilities with await import(...).
  4. Optimize images: Use loading="lazy", responsive widths, and explicit dimensions.
  5. 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 OptionRead WhenIn Initial Bundle?Optimization
path, idRoute tree generationYesKeep route paths concise
headPage navigationYesIsolate metadata strings in leaf files
loaderPre-renderYes (fn only)await import() large dependencies inside fn
pendingComponentRoute loadingYesPlain skeleton markup only - never a layout component from a UI library
componentRenderNo (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:

plugins/home/src/pages/home-page.tsx
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 HomeRouteContent

2. Lazy Dialogs and Heavy Form Editors

Heavy editors (like Tiptap) or complex modals should be lazy-loaded with React.lazy and Suspense:

plugins/blog/src/views/admin/article-editor.tsx
import React, { Suspense } from 'react'
import { Loader } from '@vitnode/core/components/ui/loader'

const RichEditor = React.lazy(async () =>
  import('@vitnode/core/components/form/fields/editor').then((mod) => ({
    default: mod.AutoFormEditor,
  })),
)

export const ArticleEditor = (props) => (
  <Suspense fallback={<Loader />}>
    <RichEditor {...props} />
  </Suspense>
)

3. Dynamic Imports Inside Loaders

When a route loader requires a heavy calculation or parsing library, import it dynamically:

plugins/stats/src/pages/stats-page.tsx
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:

apps/web/src/site/marketing/marketing.css
.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:

apps/web/src/docs/docs.css
@import '../styles.css';
@import 'fumadocs-ui/css/shadcn.css';
@import 'fumadocs-ui/css/preset.css';

@source '../../node_modules/fumadocs-ui/dist/**/*.js';
apps/web/src/routes/_docs.tsx
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.

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:

apps/web/vite.config.ts
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.


Measuring Bundle Size

Analyze your client bundle with Vite:

Build and analyze
bun run build

Inspect 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.

Learn More