Welcome! This blog just went live — I’ll be adding new posts soon. Stay tuned.Welcome! This blog just went live — I’ll be adding new posts soon. Stay tuned.
March 7, 20266 min read

Optimizing My TanStack Start Bundle

TL;DR: My blog was shipping the entire text editor to every reader. Feature folders helped with maintainability but didn't fix the bundle. Manual chunks broke the SSR build. The real villain was createLowlight(common) one line quietly bundling 180 languages nobody needed. Blog page chunk: 5.78 KB. Finally.


It Started With the Build Logs

I'd done bundle optimization on my portfolio before code splitting, lazy loading, the whole thing. So when the blog was done and deployed, I ran a build and actually looked at what was shipping. That's when I saw it: every time someone opened a blog post, they were downloading my TipTap text editor, ProseMirror's entire schema engine, and highlight.js syntax definitions for languages I've never written a post about Fortran, Arduino, Mathematica.

The editor lives on /admin/new. Readers live on /blog/$slug. Nothing in my code made that boundary real.


For a second I thought about just splitting the admin into a completely separate app clean boundary, no shared bundle, just like Notion had done. But that's the kind of move that sounds right until you're maintaining two deploys and a shared API client for a personal blog. Over-optimizing is its own spiral and I wasn't going there. The actual fix had to live inside the same codebase.

1. The Core Idea

I had a flat components/ folder. Everything lived in it blog list, admin login, the editor with all its TipTap extensions, layout wrappers, everything. It's the natural way things accumulate when you're building fast and not thinking about it.

components/
  admin/
    PostEditor.tsx   ← TipTap + ProseMirror + lowlight — all of it
    AdminLogin.tsx
    PostList.tsx
  blog/
    BlogList.tsx
  editor/
    Editor.tsx
    extensions/
  layout/
    MainLayout.tsx

The problem isn't the structure itself. The problem is that when your folder doesn't express a boundary, neither does your bundle. Vite is good at code splitting but it needs your import graph to make sense. If everything imports from the same flat @/components/* namespace, the bundler has to figure out on its own what belongs where and sometimes it just doesn't.

So I went with feature-based architecture. Give every concern its own module. features/editor/, features/admin/components/, features/blog/components/, shared/ for the stuff that genuinely gets used everywhere. The folders become an honest description of your app's boundaries, not just a place to dump files while you were thinking about something else.

features/
  blog/components/      ← BlogList, BlogItem, InteractiveBanner
  admin/components/     ← PostList, PostEditor, AdminLogin, EditorSkeleton
  editor/               ← Editor.tsx, ImageUpload, extensions/
shared/
  layout/               ← MainLayout, IdentitySidebar, SubscribeForm
  ui/                   ← button (shadcn lives here now)
  hooks/                ← useSessionIntro
  components/           ← NotFound, Unauthorized, Header
routes/                 ← untouched — TanStack Router owns this

I moved every file, updated every import path, confirmed nothing broke. Build passed. Then I checked the chunk sizes and... they barely moved.

2. How It Actually Works or How I Thought It Would

After the restructure didn't move the numbers, I read the TanStack Start docs more carefully. Turns out autoCodeSplitting is on by default. Route component functions are already split into separate chunks automatically. My .output/public/assets/ had PostEditor-*.js and AdminLogin-*.js sitting there before I changed a single thing. The editor wasn't leaking into the blog reader chunk to begin with.

So the folder work was worth doing maintainability is real and the codebase is genuinely cleaner now but it wasn't the performance fix I was expecting. I needed to look somewhere else.

3. Enough RCA lets implement the fix now

Added rollup-plugin-visualizer to actually see what I was working with:

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    tanstackStart(),
    viteReact(),
    visualizer({ open: true }), // opens treemap in browser after build
  ],
})

The treemap opened. On the right side of the editor chunk: a giant block labeled highlight.js/es/languages/*. Individual entries for gml.js, isbl.js, mathematica.js, nix.js, verilog.js, arduino.js. The entire highlight.js language catalog.

I'd already made highlight.js dynamic in the blog reader proper import('highlight.js/lib/core') in a useEffect. That part was fine. The problem was inside the editor:

// Editor.tsx — this looks innocent. It is not.
import { common, createLowlight } from 'lowlight'
const lowlight = createLowlight(common)

common is lowlight's shorthand for "bundle every language highlight.js has ever shipped." It's a convenient default that costs 350KB. No TypeScript error. No build warning. The visualizer is the only way you see it.

Before fixing that I tried manualChunks to split up the other large vendor bundles:

// First attempt — breaks the build with TanStack Start
manualChunks: {
  react: ['react', 'react-dom'],  // Error: "react" resolved as external
  router: ['@tanstack/react-router'],
}

TanStack Start marks React as external for SSR. Object syntax doesn't handle externals gracefully. Switched to function form:

// This works — path matching instead of package names
manualChunks(id) {
  if (id.includes('node_modules/@tiptap/') ||
      id.includes('node_modules/prosemirror')) return 'editor'
  if (id.includes('node_modules/@tanstack/react-query')) return 'query'
  if (id.includes('node_modules/motion')) return 'motion'
}

Then fixed the actual culprit:

// loadHeavyExtensions() — called before useEditor init, not after
const { createLowlight } = await import('lowlight')

const lowlight = createLowlight() // empty — no common, no surprise bundle

// Parallel imports — only languages that exist in my posts
const [js, ts, go, bash, python, json, css, rust, sql] = await Promise.all([
  import('highlight.js/lib/languages/javascript'),
  import('highlight.js/lib/languages/typescript'),
  import('highlight.js/lib/languages/go'),
  import('highlight.js/lib/languages/bash'),
  import('highlight.js/lib/languages/python'),
  import('highlight.js/lib/languages/json'),
  import('highlight.js/lib/languages/css'),
  import('highlight.js/lib/languages/rust'),
  import('highlight.js/lib/languages/sql'),
])

// Each import() becomes its own Vite chunk — tree-shaken correctly
lowlight.register('javascript', js.default)
lowlight.register('typescript', ts.default)
// ...rest

And the blog reader, which was already mostly right, just needed the same treatment for hljs:

// routes/blog/$slug.tsx
// SSR renders the full HTML — code blocks are readable unstyled
// useEffect fires post-hydrate, colors appear ~150ms later
useEffect(() => {
  if (!articleRef.current) return

  import('highlight.js/lib/core').then(async ({ default: hljs }) => {
    const [js, ts, bash, python, json, css] = await Promise.all([
      import('highlight.js/lib/languages/javascript'),
      import('highlight.js/lib/languages/typescript'),
      import('highlight.js/lib/languages/bash'),
      import('highlight.js/lib/languages/python'),
      import('highlight.js/lib/languages/json'),
      import('highlight.js/lib/languages/css'),
    ])
    hljs.registerLanguage('javascript', js.default)
    // ...rest

    articleRef.current!.querySelectorAll('pre code').forEach(block => {
      block.removeAttribute('data-highlighted') // reset if content re-renders
      hljs.highlightElement(block as HTMLElement)
    })
  })
}, [post.content])

4. What the Numbers Looked Like at the End

Blog page chunk (_slug): 5.78 KB. Individual highlight.js language files: 3–13KB each, fetched in parallel after hydration only on posts that have code blocks. The editor chunk (PostEditor): still ~560KB but it only downloads when someone hits /admin. That's fine. Editors are allowed to be heavy as long as readers aren't paying for it.

The server bundle still shows 1.3MB with highlight.js in it. That's normal SSR bundles the full dependency tree. The browser never downloads the server bundle. Don't let that number send you down a rabbit hole like it almost sent me.


Trade-offs & Gotchas

  • The visualizer is not optional. You can read docs about bundle splitting for hours. You won't find your actual problem until you see the treemap. Run vite-bundle-visualizer before you start changing anything.

  • createLowlight(common) will not warn you. No TypeScript error, no build warning, no ESLint rule. One alias, 180 languages. The chunk size is the only signal and only if you look.

  • Check autoCodeSplitting before you plan a big restructure. TanStack Start (and probably your framework too) might already be splitting routes. I did a full folder reorganization before discovering this. Still worth it for maintainability. Just know what you're actually getting.

  • useEffect for syntax highlighting is correct. If you're importing hljs statically to avoid the unstyled-code-block flash, you're adding ~200KB to the critical path so code blocks look slightly nicer slightly faster. SSR already rendered the HTML. The content is readable. Syntax colors are a progressive enhancement let them be one.

  • manualChunks object syntax and SSR frameworks don't mix. Check what your framework marks as external before assuming all packages are yours to split. Function form with path matching is safer.


The Takeaway

I went in thinking the folder structure was the root cause. The folder restructure improved the codebase but didn't move the bundle numbers. The actual problem was a single import alias hiding 350KB of language definitions in plain sight.

Run vite-bundle-visualizer on your project right now. Find the big blocks. Then ask yourself which of those languages you've actually written about and whether your readers should be paying the download cost for the rest of them.

© 2026 Not a Blogger.
Written by .