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(); }});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') );}Bundling notes
routekit does no codegen; whatever your bundler does with dynamic imports is what ships. A few notes:
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.