Documentation

Performance

Optimize bundle sizes, code splitting, and loading performance in VitNode and TanStack Start.

This page collects the practices that keep a VitNode app's JavaScript and CSS small and its first paint early: what lands in the initial bundle, what to lazy-load, and how to measure the result.

Checklist

  1. Keep head metadata isolated: in the app's file routes, never import UI components into 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

This table is about your app's own file routes (createFileRoute in src/routes). TanStack Router splits a route's component, errorComponent and notFoundComponent into lazy chunks, and keeps the rest in the entry bundle:

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

A plugin route is lighter by design. routes.ts holds only the declaration, and load, head, breadcrumb and the component all live in the page module that lazy() loads on demand. Only a plugin route's pendingComponent is in the entry bundle. See Routing.


Practices

1. Isolate metadata in file routes

An app file route's head is part of the entry bundle, so whatever its file imports at the top level ships to every visitor. Import the strings head needs from a small leaf module, not from the component file:

apps/web/src/routes/_main/pricing.tsx
import { createFileRoute } from '@tanstack/react-router'
import { pageHead } from '@vitnode/core/tanstack/metadata'

// BAD: pulls PricingPage and everything it imports into the entry bundle
import { PRICING_TITLE, PricingPage } from '@/site/pricing/pricing-page'

// GOOD: the strings live in a leaf module with no UI imports
import { PricingPage } from '@/site/pricing/pricing-page'
import { PRICING_DESCRIPTION, PRICING_TITLE } from '@/site/pricing/metadata'

export const Route = createFileRoute('/_main/pricing')({
  head: () => pageHead({ title: PRICING_TITLE, description: PRICING_DESCRIPTION }),
  component: PricingPage,
})

A plugin route needs none of this. Its head lives in the lazy page module and returns plain fields, { title, description, robots, alternates }. See Route module.

2. Lazy dialogs and heavy form editors

Lazy-load heavy editors (like Tiptap) and complex modals with React.lazy and Suspense. This example lazy-loads the AutoForm editor field, so a form downloads the editor only when it renders:

plugins/blog/src/views/admin/article-editor.tsx
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: React.ComponentProps<typeof RichEditor>) => (
  <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:

plugins/stats/src/pages/stats-page.tsx
import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'

export const route = defineRoute({
  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 whole AdminCP editing stack into the public bundle, AutoForm, schemas and dialogs included. Keep it to manifest, localeFiles, 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.

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:

apps/web/src/routes/_main.tsx
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:

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.

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:

src/routes/__root.tsx
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 vitnode dev both components render the stock HeadContent and Scripts. New apps from create-vitnode-app already use them.


Measure the bundle

Build the app. vitnode build prints a bundle size report with every client file, its gzip size, warnings for oversized chunks and a comparison with your previous build:

Build the app
bun run build

Bundle size report explains each part of the report, its size thresholds and --analyze. Then load a page with the browser's network panel open and check what the document shell actually requests, since a chunk the manifest never preloads can still arrive through a static import.