Skip to content

Free MIT template · Day 6 of 365

You're looking at a demo. Download the built static files (drop on any host) or grab the full source to customize.

↓ Download static ↓ Source (Astro) All 365 →Build with me →

# Code splitting

Every route handler is a function — typically a dynamic `import(...)`. This is what gives routekit its small bundle: only the route the user actually visits is downloaded.

## The pattern

    const routes = [  { path: '/',           handler: () => import('./pages/home') },  { path: '/posts/:id',  handler: () => import('./pages/post') },  { path: '/admin',      handler: () => import('./pages/admin') },] as const;

Your bundler (Vite, esbuild, Bun) sees each `import('./pages/...')` and emits a separate chunk. The main bundle ships with route definitions only — the page modules are fetched on demand.

## Loading state

Resolving a chunk takes a non-zero amount of time, especially on slow networks. Show feedback:

    router.on('navigate', async ({ match }) => {  showLoadingIndicator();  try {    const mod = await match.handler();    render(mod, match.params);  } catch (err) {    renderErrorPage(err);  } finally {    hideLoadingIndicator();  }});

Avoid the spinner-of-shame

If a chunk loads in under 100ms, don’t show a spinner — it’ll just flash on screen. Delay the spinner with a debounced `setTimeout`:

    let spinnerTimer: number | undefined;router.on('navigate', async ({ match }) => {  spinnerTimer = setTimeout(showSpinner, 150);  try {    const mod = await match.handler();    render(mod, match.params);  } finally {    clearTimeout(spinnerTimer);    hideSpinner();  }});

Result: spinner shows only when navigation actually feels slow.

## Prefetching

For routes you expect the user to visit, prefetch the chunk on hover:

    document.addEventListener('mouseover', (e) => {  const a = (e.target as HTMLElement).closest('a[href]');  if (!a) return;  const href = a.getAttribute('href')!;  const match = router.match(href);  if (match && 'handler' in match) {    void match.handler(); // fire-and-forget  }});

`router.match()` is synchronous — it returns the matched route without invoking the handler. Calling `match.handler()` separately starts the chunk download.

This pattern means by the time the user clicks, the page is already in cache. For mobile, prefetch on `touchstart` instead.

## Error handling

Dynamic imports fail in two situations: network failure (offline, dropped connection) and stale chunk hashes (chunk was renamed in a deploy, but the user’s tab still has the old route table).

    router.on('navigate', async ({ match }) => {  try {    const mod = await match.handler();    render(mod, match.params);  } catch (err) {    if (isChunkLoadError(err)) {      // Stale module map — reload the page to pick up new hashes.      window.location.reload();      return;    }    renderErrorPage(err);  }});
    function isChunkLoadError(err: unknown): err is Error {  if (!(err instanceof Error)) return false;  return (    err.message.includes('Loading chunk') ||    err.message.includes('Failed to fetch dynamically imported module')  );}

Don't infinite-loop on chunk errors

If you reload the page and the new bundle ALSO can’t load the chunk, you’ll loop. Cap the reloads:

    const reloadKey = 'routekit-chunk-reload';const reloadCount = Number(sessionStorage.getItem(reloadKey) || '0');if (reloadCount >= 1) {  renderErrorPage(new Error('Persistent chunk-load failure'));  return;}sessionStorage.setItem(reloadKey, String(reloadCount + 1));window.location.reload();

## Bundling notes

routekit does no codegen; whatever your bundler does with dynamic imports is what ships. A few notes:

- Vite
- esbuild
- Bun
- Webpack

Vite splits dynamic imports automatically. No config needed. Use `vite build --mode production` for tree-shaking.

Set `splitting: true` and `format: 'esm'`. Without splitting, dynamic imports inline into the main bundle.

`bun build --splitting --target=browser` does the right thing. Watch for the `chunkNaming` option if you want stable hashes.

`import('./pages/x')` triggers the default `splitChunks` behavior. Set `output.chunkFilename: '[name].[contenthash].js'` for cache-friendly file names.

If your bundler emits a single bundle anyway, code-splitting still works at the source level — your handler functions are just lazy in source, eager in shipped bytes. routekit’s behavior is the same either way.