van-stack
v0.1.0
Published
`van-stack` is a router-first framework for VanJS with support for CSR, SSR, SSG, and adaptive navigation. It is built around one shared route model, one shared Van render facade, and multiple runtime entrypoints.
Readme
van-stack
van-stack is a router-first framework for VanJS with support for CSR, SSR, SSG, and adaptive navigation. It is built around one shared route model, one shared Van render facade, and multiple runtime entrypoints.
Install
bun add van-stackBuild
bun run buildThat emits the publishable package into dist/ with JavaScript and .d.ts files for the root entrypoint plus the public subpaths.
Why van-stack?
- filesystem routing with reserved route-module filenames
- one route model across CSR, SSR, and SSG
- a framework-owned
van-stack/renderfacade for shared Van components - three CSR runtime modes:
hydrated,shell, andcustom - explicit hydration policies:
document-only,islands, andapp - adaptive navigation with
replaceandstack - optional Vite integration instead of Vite-coupled architecture
Package Surface
van-stack: core route model, matching, types, defaultsvan-stack/compiler: filesystem route discovery, in-memory route loading, optional manifest writingvan-stack/render: shared Van facade for route modules and demosvan-stack/csr: client router forhydrated,shell, andcustomvan-stack/ssr: request-to-HTML rendering with bootstrap payloadsvan-stack/ssg: static generation from the same route graphvan-stack/vite: optional DX adaptervan-stack/compat/vanjs-core: compatibility facade for code that importsvanjs-corevan-stack/compat/vanjs-ext: compatibility facade for code that importsvanjs-extvan-stack/compat/bun-preload: explicit Bun preload guard for unsupported runtime-plugin usagevan-stack/compat/node-register: Node resolver hook that mapsvanjs-coreandvanjs-extthrough the bound render env
How It Fits Together
- Author route modules under
src/routes. - Use
van-stack/compilerto load those routes into memory withloadRoutes({ root: "src/routes" }), or emit.van-stack/routes.generated.tswhen a browser CSR app wants route-level chunks. - Write shared route components against
van-stack/render. - Pass the loaded routes into
van-stack/csr,van-stack/ssr, orvan-stack/ssg. - Add
van-stack/viteonly if you want route-aware DX on top of the compiler layer.
Filesystem routing is the default path, but it is not mandatory. Manual route arrays still work when an app intentionally wants to bypass the compiler.
Quick Start
Route Files
src/routes/
app/
layout.ts
@sidebar/
page.ts
users/
[id]/
page.ts
loader.ts
meta.ts@slot directories are pathless route branches that attach to the nearest owning layout.ts. The default branch is still exposed as children; named branches are exposed as slots[name], and their resolved data is exposed as slotData[name].
Load Routes
import { loadRoutes } from "van-stack/compiler";
const routes = await loadRoutes({ root: "src/routes" });That gives you a runtime-ready route list. If a custom build pipeline needs a persisted artifact, the compiler can still write .van-stack/routes.generated.ts explicitly:
import { writeRouteManifest } from "van-stack/compiler";
await writeRouteManifest({ root: "src/routes" });Route Module Example
loader.ts
export default async function loader(input: {
params: { slug: string };
request: Request;
}) {
return {
post: {
slug: input.params.slug,
title: `Post: ${input.params.slug}`,
excerpt: `Notes about ${input.params.slug}`,
},
requestUrl: input.request.url,
};
}page.ts
import { van } from "van-stack/render";
const { article, h1, p } = van.tags;
export default function page(input: {
data: {
post: { title: string; excerpt: string };
};
}) {
return article(h1(input.data.post.title), p(input.data.post.excerpt));
}meta.ts
export default function meta(input: {
params: { slug: string };
data: {
post: { title: string; excerpt: string };
};
}) {
return {
title: input.data.post.title,
description: input.data.post.excerpt,
canonical: `/posts/${input.params.slug}`,
};
}layout.ts
import { van } from "van-stack/render";
const { aside, div, main } = van.tags;
export default function layout(input: {
children: unknown;
slots: Record<string, unknown>;
slotData: Record<string, unknown>;
}) {
return div(
{ class: "control-plane" },
aside(input.slots.sidebar),
main(input.children),
);
}route.ts is for raw Request -> Response handlers such as robots.txt, sitemap.xml, feeds, proxies, or webhook endpoints:
export default function route() {
return new Response("User-agent: *\nAllow: /\n", {
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}hydrate.ts is the optional low-level client enhance hook for SSR routes. app handoff uses it when a route or named slot needs to preserve and enhance existing SSR DOM; otherwise the matched page.ts remounts by default. islands routes use hydrate.ts as their normal enhancement hook:
import { van } from "van-stack/render";
export default function hydrate(input: {
root: Element;
data: { post: { likes: number } };
}) {
const likes = van.state(input.data.post.likes);
const button = input.root.querySelector("[data-like-button]");
const count = input.root.querySelector("[data-like-count]");
if (!(button instanceof HTMLButtonElement) || !(count instanceof HTMLSpanElement)) {
throw new Error("Missing hydration markers.");
}
van.hydrate(button, (dom) => {
dom.onclick = () => {
likes.val += 1;
};
return dom;
});
van.hydrate(count, (dom) => {
dom.textContent = String(likes.val);
return dom;
});
}Shell CSR Boot
import { loadRoutes } from "van-stack/compiler";
import { createRouter } from "van-stack/csr";
const routes = await loadRoutes({ root: "src/routes" });
const router = createRouter({
mode: "shell",
routes,
history: window.history,
transport: {
async load(match) {
const response = await fetch(`/_van-stack/data${match.pathname}`);
return response.json();
},
},
});
await router.load("/posts/agentic-coding-is-the-future");
await router.navigate("/posts/github-down");Chunked CSR Boot
Use the emitted route manifest when a browser CSR app wants bundler-visible lazy import() boundaries for per-route chunks:
import routes from "../.van-stack/routes.generated";
import { startClientApp } from "van-stack/csr";
const app = startClientApp({
mode: "shell",
routes,
history: window.history,
});
await app.ready;The generated manifest is the opt-in chunking path. Apps that bundle everything eagerly can keep using loadRoutes({ root: "src/routes" }), while apps that want template-wide chunking can pass chunkedRoutes into buildRouteManifest({ root, chunkedRoutes }) or writeRouteManifest({ root, chunkedRoutes }).
App Hydration Handoff
For SSR branches using hydrationPolicy: "app", the recommended browser entry is the managed hydrated client mode:
import { loadRoutes } from "van-stack/compiler";
import { startClientApp } from "van-stack/csr";
const routes = await loadRoutes({ root: "src/routes" });
const app = startClientApp({
mode: "hydrated",
routes,
history: window.history,
});
await app.ready;startClientApp({ mode: "hydrated" }) uses hydrateApp(...) as the initial SSR handoff orchestrator. hydrateApp(...) reads the bootstrap payload, finds the app root, and then applies the default app strategy:
- if the matched route or named slot ships
hydrate.ts, run that low-level enhance hook against the existing SSR DOM - otherwise resolve the matched
page.tsand remount that branch into the app root or slot root
After the initial handoff, later client renders follow the same rule. hydrated remains the CSR mode name; remount is the default handoff strategy inside that mode, not a separate runtime mode.
Islands Hydration
For SSR branches using hydrationPolicy: "islands", you can hydrate focused route islands without creating a client router:
import { loadRoutes } from "van-stack/compiler";
import { hydrateIslands } from "van-stack/csr";
const routes = await loadRoutes({ root: "src/routes" });
const hydration = hydrateIslands({ routes });
await hydration.ready;hydrateIslands(...) reads the SSR bootstrap payload, resolves the matched route hydrate.ts, and runs that low-level enhancement against the server-owned document without taking over navigation.
API Tour
van-stack/compiler
Use the compiler when you want filesystem routing:
import { loadRoutes, writeRouteManifest } from "van-stack/compiler";
const routes = await loadRoutes({ root: "src/routes" });
// Optional emitted artifact for chunked browser CSR or custom build tooling.
await writeRouteManifest({ root: "src/routes" });loadRoutes(...) is still the recommended default. Writing .van-stack/routes.generated.ts is the opt-in path when a browser CSR app wants route-level JS chunks.
van-stack/render
Route modules should import Van and VanX through the framework facade, not from concrete client or server packages:
import { van, vanX } from "van-stack/render";
const { button, div, p } = van.tags;
export default function page() {
const count = van.state(0);
const post = vanX.reactive({
title: "Increment Demo",
likes: 0,
});
return div(
p(() => post.title),
button(
{
onclick: () => {
count.val += 1;
post.likes += 1;
},
},
"Increment",
),
p(() => `Count: ${count.val}`),
p(() => `Likes: ${post.likes}`),
);
}The render facade also exposes van.hydrate(...) for route-level hydrate.ts modules. In app handoff flows that file is the optional low-level enhance hook; in islands flows it is the normal activation path. Under the hood, CSR binds the real VanX runtime while SSR and SSG bind the server-safe VanX placeholder recommended by the official Van fullstack SSR pattern.
Third-Party Van Libraries
First-party route code should still use van-stack/render. Compatibility shims exist for imported packages that hard-import vanjs-core or vanjs-ext directly:
import { vanStackVite, getVanStackCompatAliases } from "van-stack/vite";Use vanStackVite() for Vite apps, or reuse getVanStackCompatAliases() in Vitest and custom Vite configs so those packages resolve through the bound van-stack/render environment. For direct Node SSR and SSG entrypoints, start the process with van-stack/compat/node-register.
For Bun SSR and SSG entrypoints, run Bun with the shipped compat override:
bun run --tsconfig-override ./node_modules/van-stack/compat/bun-tsconfig.json ./src/server.tsvan-stack/compat/bun-preload is intentionally unsupported. Bun runtime plugins do not intercept bare package imports during bun run, so Bun needs the tsconfig override path instead.
For a repeatable app setup, add a dedicated Bun tsconfig and call it from package scripts:
tsconfig.bun.json
{
"extends": "./node_modules/van-stack/compat/bun-tsconfig.json"
}package.json
{
"scripts": {
"ssr": "bun run --tsconfig-override ./tsconfig.bun.json ./src/server.ts",
"ssg": "bun run --tsconfig-override ./tsconfig.bun.json ./src/build.ts"
}
}bunfig.toml does not currently expose a tsconfig override setting, so the supported Bun DX path is a checked-in tsconfig.bun.json plus package script aliases.
Compatibility only works when the resolver hook runs before those third-party modules are evaluated. In practice that means you must bind the render env before module evaluation reaches any imported library that reads van or vanX eagerly.
van-stack/csr
van-stack/csr supports three runtime modes:
hydrated: continue from SSR HTML and bootstrap datashell: boot from a tiny document and use transport-backed loadingcustom: boot from a tiny document and keep data ownership in the host app or in components
For framework-owned client rendering, use startClientApp({ routes, ... }). It accepts either eager route arrays or lazy manifest-shaped routes from .van-stack/routes.generated.ts:
import routes from "../.van-stack/routes.generated";
import { startClientApp } from "van-stack/csr";
const app = startClientApp({
mode: "shell",
routes,
history: window.history,
});
await app.ready;Resolver-driven custom mode:
import { createRouter } from "van-stack/csr";
const routes = [{ id: "posts/[slug]", path: "/posts/:slug" }];
const router = createRouter({
mode: "custom",
routes,
history: window.history,
async resolve(match) {
return graphqlClient.query({
query: PostBySlugDocument,
variables: { slug: match.params.slug },
});
},
});Component-owned custom mode:
const router = createRouter({
mode: "custom",
routes,
history: window.history,
});In that second shape, VanStack owns route matching, params, query parsing, history, and navigation. Route data is not preloaded; components fetch for themselves.
For SSR handoff in app hydration mode, prefer hydrateApp(...) over manual bootstrap wiring:
import { hydrateApp } from "van-stack/csr";
const app = hydrateApp({ routes });
await app.ready;
app.router.subscribe((entry) => {
console.log(entry.path);
});van-stack/ssr
SSR consumes the same route graph and returns HTML plus bootstrap state:
import { loadRoutes } from "van-stack/compiler";
import { renderRequest } from "van-stack/ssr";
const routes = await loadRoutes({ root: "src/routes" });
const response = await renderRequest({
request,
routes,
});
console.log(response.status);
console.log(await response.text());request is the incoming server/runtime request object.
For non-HTML endpoints such as robots.txt, sitemap.xml, or reverse-proxy style content routes, define route.ts and return a raw Response.
van-stack/ssg
SSG also consumes the same route graph:
import { loadRoutes } from "van-stack/compiler";
import { buildStaticRoutes, exportStaticSite } from "van-stack/ssg";
const routes = await loadRoutes({ root: "src/routes" });
const artifacts = await buildStaticRoutes({ routes });
await exportStaticSite({
routes,
outDir: "dist",
assets: [{ from: "public" }],
});buildStaticRoutes(...) is the in-memory primitive for caches, tests, and previews. exportStaticSite(...) writes deployable static output for generic web servers.
Routes that participate in SSG should provide entries.ts so dynamic params can expand into concrete paths. That applies to both page.ts HTML routes and raw route.ts outputs such as robots.txt, feed.xml, or sitemap.xml.
Runtime Model
CSR Modes
hydrated: web browser starts from SSR HTML, then continues as a client appshell: app starts from a tiny HTML shell and uses VanStack-owned route loadingcustom: app starts from a tiny HTML shell and owns its data loading strategy
Hydration Policies
document-only: SSR HTML onlyislands: SSR HTML plus targeted client activationapp: SSR HTML followed by full client-router handoff
In practice, app handoff means SSR emits bootstrap state and an app root, while the client uses the hydrated CSR mode to resume from that first route. If a route or named slot ships hydrate.ts, VanStack preserves the SSR DOM and runs that low-level enhance hook; otherwise it resolves the matching page.ts and remounts that branch by default before continuing with router takeover.
Hydration policy is about how SSR output becomes interactive. CSR mode is about how a client router boots and where data comes from.
Presentation Modes
replace: browser-style view replacementstack: mobile-style pushed views
Presentation is separate from route matching and data loading. The same route tree can present as replace on desktop and stack on mobile or Tauri shells.
Demos
For the fastest evaluator path, run the showcase from the repo root:
bun run startdemo/showcase: evaluator-first demo workspace for the shared blog appRuntime Gallery: livessg,ssr,hydrated,islands,shell,custom, andchunkedcomparisons against one Northstar Journal blog app- Post detail routes demonstrate server-backed likes and bookmarks, with default remount handoff for
hydratedand low-level enhance hooks forislands Guided Walkthrough: annotated evaluator pages that explain those same seven modes and link back to the live routesAdaptive Navigation: a separatestackpresentation track over the same blog graph
demo/csr: focused reference forhydrated,shell, andcustomclient boot patternsdemo/chunked-csr: focused reference for route-level CSR chunking throughchunkedRoutes,.van-stack/routes.generated.ts, andstartClientApp({ routes }), including a/shell-workbench/overviewcontrol-plane route built fromlayout.tsplus a pathless@sidebarslotdemo/ssr-blog: focused reference for SSR blog routes, slug loaders, and bootstrap handoffdemo/ssg-site: focused reference for static generation from route entries, rawroute.tsoutputs, and exported asset trees that can be served by generic web servers; runbun ./demo/ssg-site/build.tsto writedemo/ssg-site/dist/demo/adaptive-nav: focused reference forreplacevsstackpresentationdemo/third-party-compat: focused reference for libraries that importvanjs-coreandvanjs-extdirectly, rendered throughvan-stack/vitein CSR,van-stack/compat/node-registerin Node SSR and SSG, andcompat/bun-tsconfig.jsonin Bun SSR and SSG
