Split Routes Lazily
Goal: keep a heavy page's render code (and its dependencies) out of the initial bundle, loading it only when its route is actually rendered.
Wrap the route's component in Router.lazy. The route descriptor (its segment and param schemas) stays eager so the matcher, href, and the server's dispatch API still see it statically — only the component body is split into its own chunk.
import { Router } from "@weftui/router";
import { Schema } from "effect";
Router.route("docs/:category/:slug", {
path: { category: Schema.String, slug: Schema.String },
component: Router.lazy(() => import("./doc-page").then((m) => m.DocPage)),
});The chunk loads on the server during render and on the client on navigation; only the matched branch's chunks are ever fetched. E/R are preserved — a lazy route has the exact same channels as the same component declared eagerly, so an unmet service requirement is still a compile error at Router.router(...).
Make the split real
Router.lazy only splits if the dynamic import() is the only eager path to the heavy module. Keep the Router.route(…) descriptor in an eagerly-imported file, and move the component implementation (and its heavy deps) into a separate module referenced only through Router.lazy(() => import("./impl")):
// routes.ts — eager, tiny: just the descriptor
export const docsRoute = Router.route("docs/:category/:slug", {
path: { category: Schema.String, slug: Schema.String },
component: Router.lazy(() => import("./doc-page-impl").then((m) => m.DocsPage)),
});
// doc-page-impl.ts — heavy: pulled into its own chunk, never in the initial graph
export const DocsPage = Component.gen(function* () {
/* renderHast, code highlighting, … */
});A descriptor file that still imports the impl statically gains nothing — the bundler keeps it in the initial graph.
What you get for free
- Flash-free hydration. On a directly-loaded lazy route, the client re-invokes the same slot, awaits the chunk, and adopts the server DOM in place — the first production matches, so nothing is mutated.
- Blank-free navigation. Client navigation is deferred-commit: the router resolves the target branch's chunk before committing the URL, so the previous page stays mounted during the fetch and the swap is a single tick. See Show Navigation Progress for the
Router.navigatingsignal this exposes. - Synchronous revisits.
Router.lazymemoizes its load per slot, so a second visit to a loaded route commits immediately.
Edge cases
- Lazy layouts. A
Router.layout({ component: Router.lazy(...) })splits too — each lazy node in the matched branch is awaited; nodes outside it never load. - Chunk-load failure is a defect. If the
import()rejects (offline, or a stale client requesting a chunk a new deploy removed), it dies as a defect and surfaces through normal defect handling — it never hangs or silently 404s. The rejection is memoized, so the route keeps failing until a reload (the deploy-skew case). - Not a lazy subtree. Only the component is lazy; you cannot defer a whole
RouteNodebehind animport(), because the matcher needs every leaf's segment and param schema before anything loads.
See also
Router.lazyAPI reference- Show Navigation Progress — the deferred-commit
Router.navigatingsignal - Add Routing — authoring the route tree
Router.lazyplugs into - examples/router-ssr — includes a
Router.lazypage (lazy-page.ts) with a browser test