Weftv0.23.1
GitHub

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.

typescript
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")):

typescript
// 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.navigating signal this exposes.
  • Synchronous revisits. Router.lazy memoizes 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 RouteNode behind an import(), because the matcher needs every leaf's segment and param schema before anything loads.

See also