van-stack
v0.5.0
Published
`van-stack` is a router-first framework for VanJS with one shared route model across CSR, SSR, and SSG. The default path is filesystem routing from `src/routes`, route components written against official Van packages, and the same route graph flowing into
Downloads
632
Readme
van-stack
van-stack is a router-first framework for VanJS with one shared route model across CSR, SSR, and SSG. The default path is filesystem routing from src/routes, route components written against official Van packages, and the same route graph flowing into the runtime you need.
Install
bun add van-stackStart Here
- Create route modules under
src/routes. - Load them with
loadRoutes({ root: "src/routes" })in Node/build/server code, orvirtual:van-stack/routesin Vite browser CSR. - Write route components with official Van imports such as
vanjs-coreand optionalvanjs-ext. - Pass those routes into
van-stack/csr,van-stack/ssr, orvan-stack/ssg.
If you want one place to evaluate the full framework before wiring your own app, start with demo/showcase and run:
bun run startHappy-Path Quick Start
The normal filesystem-routing path looks like this:
src/routes/
(public)/
layout.ts
login/
page.ts
(private)/
layout.ts
dashboard/
page.ts
app/
layout.ts
@sidebar/
page.ts
posts/
[slug]/
page.ts
loader.ts
meta.tsParenthesized directories such as (public) and (private) are pathless route groups. Their names stay in route IDs, their layout.ts files wrap descendants, and they do not appear in public URLs. For example, src/routes/(public)/login/page.ts matches /login. Route groups separate route concerns; they do not add authorization by themselves. VanStack rejects duplicate public route patterns, so (public)/login and (private)/login cannot both claim /login.
@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 the route tree in memory from Node, build, SSR, SSG, or custom tooling:
import { loadRoutes } from "van-stack/compiler";
const routes = await loadRoutes({ root: "src/routes" });Write route components against the official Van package so the same page.ts can run in CSR, SSR, and SSG:
// src/routes/posts/[slug]/page.ts
import van from "vanjs-core";
const { article, h1, p } = van.tags;
export default function page(input: {
data: {
post: { title: string; excerpt: string };
};
params: { slug: string };
query: URLSearchParams;
path: string;
pathname: string;
}) {
const view = input.query.get("view") ?? "summary";
return article(
h1(input.data.post.title),
p(input.data.post.excerpt),
p(`Route ${input.params.slug} is showing ${view}.`),
);
}Add route data and metadata with the reserved route-module files:
// 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,
};
}// 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}`,
};
}For a Vite browser CSR entry, let van-stack/vite run the compiler during dev/build and import the browser-safe route module:
// vite.config.ts
import { defineConfig } from "vite";
import { vanStackVite } from "van-stack/vite";
export default defineConfig({
plugins: [vanStackVite({ routes: { root: "src/routes" } })],
});/// <reference types="van-stack/vite/client" />
import routes from "virtual:van-stack/routes";
import { startClientApp } from "van-stack/csr";
const app = startClientApp({
mode: "custom",
routes,
history: window.history,
});
await app.ready;Choose the runtime handoff you want:
// CSR shell boot
import { createRouter } from "van-stack/csr";
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();
},
},
});// SSR request rendering
import { renderRequest } from "van-stack/ssr";
const response = await renderRequest({
request,
routes,
});// SSR server wiring
import { createServer } from "node:http";
import { renderRequest } from "van-stack/ssr";
const port = Number(process.env.PORT ?? "3000");
createServer(async (req, res) => {
const request = new Request(`http://${req.headers.host}${req.url ?? "/"}`, {
method: req.method,
headers: req.headers as Record<string, string>,
});
const response = await renderRequest({ request, routes });
res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
res.end(Buffer.from(await response.arrayBuffer()));
}).listen(port);// SSR with Cloudflare Workers
import { loadRoutes } from "van-stack/compiler";
import { renderRequest } from "van-stack/ssr";
const routes = await loadRoutes({ root: "src/routes" });
export default {
async fetch(request: Request): Promise<Response> {
return renderRequest({
request,
routes,
});
},
};// SSR with Express
import express from "express";
import { renderRequest } from "van-stack/ssr";
const app = express();
const port = Number(process.env.PORT ?? "3000");
app.use(async (req, res) => {
const request = new Request(`${req.protocol}://${req.get("host")}${req.originalUrl}`, {
method: req.method,
headers: req.headers as Record<string, string>,
});
const response = await renderRequest({ request, routes });
res.status(response.status);
response.headers.forEach((value, name) => {
res.setHeader(name, value);
});
res.send(Buffer.from(await response.arrayBuffer()));
});
app.listen(port);van-stack/ssr renders a Request into a Response, but it does not create a server or choose a listen port. The app-owned server entrypoint is responsible for calling listen(...), usually from process.env.PORT.
// SSG export
import { exportStaticSite } from "van-stack/ssg";
await exportStaticSite({
routes,
outDir: "dist",
assets: [{ from: "public" }],
});That is the core flow: route files in src/routes, shared UI via official Van imports, route loading through the compiler or Vite virtual module, then CSR, SSR, or SSG on top of the same route graph.
Why van-stack?
- filesystem routing with reserved route-module filenames
- one route model across CSR, SSR, and SSG
- official
vanjs-coreand optionalvanjs-extimports for route 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/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 browser CSR route adapter forvirtual:van-stack/routesvan-stack/compat/vanjs-core: SSR/SSG compatibility module for packages that importvanjs-corevan-stack/compat/vanjs-ext: SSR/SSG compatibility module for packages that importvanjs-extvan-stack/compat/bun-tsconfig.json: Bun SSR/SSG resolver override for third-party Van packagesvan-stack/compat/bun-preload: explicit Bun preload guard for unsupported runtime-plugin usagevan-stack/compat/node-register: Node SSR/SSG resolver hook that mapsvanjs-coreandvanjs-extto server/static-safe compat modules
How It Fits Together
- Author route modules under
src/routes. - Use
van-stack/compilerto load those routes in Node/build/server code withloadRoutes({ root: "src/routes" }), or usevanStackVite({ routes: { root: "src/routes" } })plusvirtual:van-stack/routesin Vite browser CSR. - Write route components against
vanjs-core, and importvanjs-extdirectly when VanX helpers are needed. - Pass the loaded routes into
van-stack/csr,van-stack/ssr, orvan-stack/ssg. - Add
van-stack/viteonly if you want route-aware browser CSR 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.
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" });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 artifact and chunking path. Node, SSR, SSG, and build tooling can keep using loadRoutes({ root: "src/routes" }); Vite browser CSR apps should use virtual:van-stack/routes; apps that want template-wide emitted chunk metadata can pass chunkedRoutes into buildRouteManifest({ root, chunkedRoutes }) or writeRouteManifest({ root, chunkedRoutes }).
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
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 custom mode without a resolver, route components still receive the matched route context:
// src/routes/new-esim/[iccid]/page.ts
import van from "vanjs-core";
const { article, h1, p } = van.tags;
export default function page(input: {
params: { iccid: string };
query: URLSearchParams;
path: string;
pathname: string;
data: unknown;
}) {
const step = input.query.get("step") ?? "start";
return article(
h1(`eSIM ${input.params.iccid}`),
p(`Current step: ${step}`),
p(`Matched path: ${input.pathname}`),
);
}Hydration Policies
document-only: SSR HTML onlyislands: SSR HTML plus targeted client activationapp: SSR HTML followed by full client-router handoff
For SSR branches using hydrationPolicy: "app", the recommended browser entry is the managed hydrated client mode:
/// <reference types="van-stack/vite/client" />
import routes from "virtual:van-stack/routes";
import { startClientApp } from "van-stack/csr";
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 remounts that branch by default before continuing with router takeover
For SSR branches using hydrationPolicy: "islands", you can hydrate focused route islands without creating a client router:
/// <reference types="van-stack/vite/client" />
import routes from "virtual:van-stack/routes";
import { hydrateIslands } from "van-stack/csr";
const hydration = hydrateIslands({ routes });
await hydration.ready;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.
Compatibility And Tooling Notes
Route modules should import Van through the official Van packages:
// src/routes/index/page.ts
import van from "vanjs-core";VanX helpers use the official extension package directly:
import * as vanX from "vanjs-ext";Compatibility shims are SSR/SSG-only. They exist for server and static entrypoints that import first-party route modules or third-party packages which import vanjs-core or vanjs-ext directly, where the browser Van packages do not match the server/static runtime environment.
Browser CSR does not use VanStack compatibility aliases. It imports the real browser vanjs-core, and packages that need VanX should import vanjs-ext directly.
If shared code must branch on browser-only behavior, check for window, not document. SSR/SSG may provide a minimal server document so official Van tags can render safely.
For the default Node loadRoutes({ root }) path, VanStack installs the Node resolver hook before route module factories are evaluated. For direct route imports or custom generated-manifest 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.tsFor 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. van-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.
Compatibility only works when the resolver hook runs before those third-party modules are evaluated. In practice that means SSR and SSG entrypoints must install the hook before module evaluation reaches any imported library that reads Van eagerly.
Demos And Docs
demo/showcase: main evaluator demo covering gallery, guided walkthroughs,ssr,ssg,hydrated,islands,shell,custom, and chunked flowsdemo/chunked-csr: chunked browser CSR demo using.van-stack/routes.generated.tsdemo/third-party-compat: SSR/SSG compatibility demo for packages that importvanjs-coreorvanjs-extdocs/getting-started.md: focused setup and recommended defaultsdocs/demos.md: demo indexdocs/bun.md: Bun-specific compatibility and workflow guidancedemo/adaptive-nav: focused adaptive navigation demo
For deployable static output, exportStaticSite(...) writes HTML pages, raw route.ts outputs, and copied asset files/directories into a static tree that generic web servers can serve directly.
