Fullstack web framework with SSR, hydration, and file-based conventions.
- Server: Hono (web framework) + Preact SSR
- Client: Preact + @preact/signals + wouter-preact
- Build: Vite with custom plugin (Babel AST transforms)
npx @mauroandre/velojs init my-app
cd my-app
npm install
npx velojs devmy-app/
├── app/
│ ├── routes.tsx # Route definitions (export default)
│ ├── server.tsx # Server init (DB connections, custom routes, etc)
│ ├── client.tsx # Client init (global CSS, etc)
│ ├── client-root.tsx # Root component (<html>, <head>, <body>)
│ └── pages/ # Pages, layouts, modules
├── vite.config.ts
├── tsconfig.json
└── package.json
import { defineConfig } from "vite";
import { veloPlugin } from "@mauroandre/velojs/vite";
export default defineConfig({
plugins: [veloPlugin()],
});{
"scripts": {
"dev": "velojs dev",
"build": "velojs build",
"start": "velojs start"
}
}The root component renders the HTML shell. It must accept children and include <Scripts />.
import type { ComponentChildren } from "preact";
import { Scripts } from "@mauroandre/velojs";
export const Component = ({ children }: { children?: ComponentChildren }) => (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<Scripts />
</head>
<body>{children}</body>
</html>
);Runs on the client only. Use it to import global CSS, initialize client-side libraries, or set up global components like toasts.
// Import global styles
import "./styles/global.css";
// Optional: set up global client-side features
// import { initAnalytics } from "./modules/analytics.js";
// initAnalytics();Runs on the server only. Use it to connect to databases, create indexes, register custom API routes, start background jobs, and set up WebSocket handlers.
import type { Hono } from "hono";
import { addRoutes, onServer } from "@mauroandre/velojs/server";
// Connect to database
import { connectDB } from "../db/engine.js";
await connectDB();
// Create indexes
import { getDB } from "../db/engine.js";
const db = getDB();
await db.collection("users").createIndex({ email: 1 }, { unique: true });
// Register custom API routes
addRoutes((app: Hono) => {
app.get("/api/health", (c) => c.json({ ok: true }));
});
// Start background jobs
const { runCleanup } = await import("./modules/cleanup.js");
setInterval(() => runCleanup().catch(console.error), 60_000);import type { AppRoutes } from "@mauroandre/velojs";
import * as Root from "./client-root.js";
import * as Home from "./pages/Home.js";
export default [
{
module: Root,
isRoot: true,
children: [
{ path: "/", module: Home },
],
},
] satisfies AppRoutes;import type { LoaderArgs } from "@mauroandre/velojs";
import { useLoader } from "@mauroandre/velojs/hooks";
export const loader = async ({ c }: LoaderArgs) => {
return { message: "Hello, VeloJS!" };
};
export const Component = () => {
const { data } = useLoader<{ message: string }>();
return <h1>{data.value?.message}</h1>;
};npm run dev # http://localhost:3000veloPlugin({
appDirectory: "./app", // default
routesFile: "routes.tsx", // default
serverInit: "server.tsx", // default
clientInit: "client.tsx", // default
});Routes are defined in app/routes.tsx as a tree structure. Each node can have a module (component + loader + actions), children (nested routes), and middlewares.
// app/routes.tsx
import type { AppRoutes } from "@mauroandre/velojs";
import * as Root from "./client-root.js";
import * as AuthLayout from "./auth/Layout.js";
import * as Login from "./auth/Login.js";
import * as AdminLayout from "./admin/Layout.js";
import * as Dashboard from "./admin/Dashboard.js";
import * as Users from "./admin/Users.js";
import * as UserDetail from "./admin/UserDetail.js";
import { authMiddleware } from "./modules/auth/auth.middleware.js";
export default [
{
module: Root,
isRoot: true,
children: [
// Public routes
{
module: AuthLayout,
children: [
{ path: "/login", module: Login },
],
},
// Authenticated routes
{
module: AdminLayout,
middlewares: [authMiddleware],
children: [
{ path: "/", module: Dashboard },
{ path: "/users", module: Users },
{ path: "/users/:id", module: UserDetail },
],
},
],
},
] satisfies AppRoutes;Routes with children act as layouts. Their Component receives children and wraps nested routes. VeloJS renders the full hierarchy from root to leaf:
GET /users/123 renders:
Root (isRoot — <html>, <head>, <body>)
└─ AdminLayout (sidebar, nav)
└─ UserDetail (page content)
// app/client-root.tsx — Root component
import { Scripts } from "@mauroandre/velojs";
export const Component = ({ children }: { children: any }) => (
<html>
<head><Scripts /></head>
<body>{children}</body>
</html>
);
// app/admin/Layout.tsx — Layout component
export const Component = ({ children }: { children: any }) => (
<div class={css.layout}>
<nav class={css.sidebar}>...</nav>
<main class={css.content}>{children}</main>
</div>
);
// app/admin/UserDetail.tsx — Page component (leaf, no children)
export const Component = () => {
const { data } = useLoader<User>();
return <div>{data.value?.name}</div>;
};Every layout and page can have its own loader. On a request, all loaders in the hierarchy run in parallel — Root loader + AdminLayout loader + UserDetail loader all execute at the same time.
| Property | Type | Description |
|---|---|---|
path |
string |
URL path segment. Supports :params (e.g., /users/:id). |
module |
RouteModule |
Module with Component, loader, action_* |
children |
RouteNode[] |
Nested routes (module acts as layout) |
middlewares |
MiddlewareHandler[] |
Hono middlewares (server-only, inherited by children) |
isRoot |
boolean |
Marks the root node (renders <html>, <head>, <body>) |
Paths are relative segments that concatenate with parent paths:
Root (no path)
└─ AdminLayout (no path)
├─ Dashboard → path: "/" → fullPath: "/"
├─ Users → path: "/users" → fullPath: "/users"
└─ UserDetail → path: "/users/:id" → fullPath: "/users/:id"
Nodes without path don't add a segment — they're pure layout wrappers. The Vite plugin parses routes.tsx at build-time and calculates both fullPath (absolute) and path (relative segment), injecting them into each module's metadata export.
You can reuse the same layout for different route groups:
export default [
{
module: Root,
isRoot: true,
children: [
// Public pages — same layout, no auth
{
module: PublicLayout,
children: [
{ path: "/", module: Home },
{ path: "/about", module: About },
],
},
// Dashboard — same root, different layout + auth
{
path: "/dashboard",
module: DashboardLayout,
middlewares: [authMiddleware],
children: [
{ path: "/", module: Overview },
{ path: "/settings", module: Settings },
],
},
],
},
] satisfies AppRoutes;| Export | Purpose |
|---|---|
export const Component |
Preact component (required) |
export const loader |
Server-side data loader |
export const action_* |
Server-side actions (RPC) |
// app/admin/Users.tsx
import type { LoaderArgs, ActionArgs } from "@mauroandre/velojs";
import { useLoader } from "@mauroandre/velojs/hooks";
interface User { id: string; name: string; }
export const loader = async ({ params, query, c }: LoaderArgs) => {
const { getUsers } = await import("./user.service.js");
return getUsers();
};
export const action_delete = async ({
body,
c,
}: ActionArgs<{ id: string }>) => {
const { deleteUser } = await import("./user.service.js");
await deleteUser(body.id);
return { ok: true };
};
export const Component = () => {
const { data, loading, refetch } = useLoader<User[]>();
if (loading.value) return <div>Loading...</div>;
return (
<ul>
{data.value?.map((u) => (
<li key={u.id}>
{u.name}
<button onClick={async () => {
await action_delete({ body: { id: u.id } });
refetch();
}}>Delete</button>
</li>
))}
</ul>
);
};Loaders and actions run on the server, but the file itself is also bundled for the client (the Vite plugin strips the loader body and transforms actions into fetch stubs). This means top-level imports are included in the client bundle.
Always use await import() inside loaders and actions for server-only code (database access, file system, secrets, etc.):
// BAD — leaks server code into client bundle
import { getUsers } from "./user.service.js";
import { db } from "../db/engine.js";
export const loader = async () => {
return db.collection("users").find().toArray();
};
// GOOD — dynamic import, only runs on server
export const loader = async () => {
const { getUsers } = await import("./user.service.js");
return getUsers();
};This is the most important convention in VeloJS. If you top-level import a module that uses Node.js APIs (fs, crypto, database drivers), the client build will fail or include unnecessary code.
Two patterns for consuming loader data:
Use for page-specific data. Supports SSR hydration and SPA navigation (auto-fetches on navigation).
export const Component = () => {
const { data, loading, refetch } = useLoader<MyType>();
// data: Signal<T | null>
// loading: Signal<boolean>
// refetch: () => void — manually re-fetch data
};With dependencies (re-fetch when deps change):
const params = useParams<{ id: string }>();
const { data } = useLoader<User>([params.id]);Use for global/shared data loaded in a Layout and exported to child modules. Runs once on import — does not re-fetch on SPA navigation.
// app/admin/Layout.tsx
import { Loader } from "@mauroandre/velojs/hooks";
export const { data: globalData } = Loader<GlobalType>();
export const Component = ({ children }) => (
<div>
<header>Hello, {globalData.value?.user.name}</header>
{children}
</div>
);
// app/admin/Home.tsx — import from Layout
import { globalData } from "./Layout.js";
export const Component = () => (
<div>Permissions: {globalData.value?.permissions.join(", ")}</div>
);SSR:
loader() → server runs all loaders in parallel
→ injects window.__PAGE_DATA__ = { moduleId: data, ... }
→ Loader()/useLoader() hydrate from __PAGE_DATA__
SPA navigation:
useLoader() → fetch(currentPath?_data=1) → JSON { moduleId: data }
Loader() → returns null (no re-fetch)
Server-side functions callable from the client via RPC.
export const action_login = async ({
body,
c,
}: ActionArgs<{ email: string; password: string }>) => {
const { authenticate } = await import("./auth.service.js");
const token = await authenticate(body.email, body.password);
const { setCookie } = await import("@mauroandre/velojs/cookie");
setCookie(c!, "session", token, { path: "/" });
return { ok: true };
};The Vite plugin transforms action bodies into fetch stubs at build time:
// Original (server)
export const action_login = async ({ body, c }: ActionArgs<LoginBody>) => {
// ... server logic
};
// Transformed (client)
export const action_login = async ({ body }: { body: LoginBody }) => {
return fetch("/_action/auth/Login/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(r => r.json());
};Error handling: Actions do NOT throw on server errors. They resolve with { error: "message" }. Always check result.error explicitly.
All hooks work in both SSR and client (via AsyncLocalStorage on server, wouter/DOM on client).
| Hook | Description |
|---|---|
useLoader<T>(deps?) |
Loader data with SSR + SPA support. Returns { data, loading, refetch } |
Loader<T>() |
Module-level SSR-only loader. Returns { data, loading } |
useParams<T>() |
Route parameters (e.g., :id) |
useQuery<T>() |
Query string parameters |
useNavigate() |
Programmatic navigation. Returns navigate(path) function |
usePathname() |
Absolute pathname (unlike wouter's useLocation which is relative to nest context) |
touch(signal) |
Force signal notification after nested property mutation |
const items = useSignal<Item[]>([]);
// Mutating nested properties doesn't trigger signal updates
items.value[0].checked = true;
// touch() forces the update
touch(items);Navigation with type-safe module references or string paths.
import { Link } from "@mauroandre/velojs";
import * as UserPage from "./users/UserDetail.js";
import * as LoginPage from "./auth/Login.js";
// With route module (relative — uses metadata.path, works with wouter nest context)
<Link to={UserPage} params={{ id: "123" }}>View</Link>
// With route module (absolute — uses metadata.fullPath)
<Link to={LoginPage} absolute>Login</Link>
// With query string
<Link to={UserPage} params={{ id: "123" }} search={{ tab: "settings" }}>
Settings
</Link>
// String path (relative to current nest context)
<Link to="/users">Users</Link>
// String path with ~/ prefix (absolute — escapes nest context)
<Link to="~/stacks">Stacks</Link>
<Link to={`~/stacks/apps/${appId}/edit`}>Edit App</Link>VeloJS uses wouter-preact for routing. When routes are nested (layouts wrapping children), wouter creates a nest context — relative paths resolve within the current layout's scope.
The ~/ prefix escapes the nest context and navigates from the root. Use it when navigating between sections:
// Inside /master/workers layout, these behave differently:
<Link to="/details"> → resolves to /master/workers/details (relative)
<Link to="~/stacks"> → resolves to /stacks (absolute from root)When to use ~/: anytime you navigate to a route outside the current layout's scope. In practice, most cross-section links use ~/.
| Prop | Type | Description |
|---|---|---|
to |
string | RouteModule |
Destination path or module. String paths support ~/ prefix for absolute navigation |
params |
Record<string, string> |
URL parameter substitution (:id → value) |
search |
Record<string, string> |
Query string parameters |
absolute |
boolean |
When using module reference: use fullPath instead of path (default: false) |
Injects necessary scripts and styles in <head>.
import { Scripts } from "@mauroandre/velojs";
export const Component = ({ children }) => (
<html>
<head>
<Scripts />
</head>
<body>{children}</body>
</html>
);| Prop | Type | Default | Description |
|---|---|---|---|
basePath |
string |
process.env.STATIC_BASE_URL || "" |
Base path for static assets |
favicon |
string | false |
"/favicon.ico" |
Favicon path, or false to disable |
Development:
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<script type="module" src="/@vite/client"></script>
<script type="module" src="/__velo_client.js"></script>Production:
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/client.css" />
<script type="module" src="/client.js"></script>Server-side only. Removed from client bundle at build time.
Use createMiddleware from velojs/factory (wraps Hono's middleware):
// app/modules/auth/auth.middleware.ts
import { createMiddleware } from "@mauroandre/velojs/factory";
import { getCookie } from "@mauroandre/velojs/cookie";
export const authMiddleware = createMiddleware(async (c, next) => {
const token = getCookie(c, "session");
if (!token) {
if (c.req.method === "GET") return c.redirect("/login");
return c.json({ error: "unauthorized" }, 401);
}
// Set data on context — accessible in loaders and actions via c.get()
const user = await verifyToken(token);
c.set("user", user);
await next();
});Add middlewares to any route node. All children inherit the middleware:
// app/routes.tsx
import { authMiddleware } from "./modules/auth/auth.middleware.js";
import { masterMiddleware } from "./modules/auth/master.middleware.js";
export default [
{
module: Root,
isRoot: true,
children: [
// Public routes — no middleware
{ path: "/login", module: AuthLayout, children: [{ module: Login }] },
// Authenticated routes
{
module: AdminLayout,
middlewares: [authMiddleware],
children: [
{ path: "/", module: Dashboard }, // authMiddleware applies
{ path: "/stacks", module: Stacks }, // authMiddleware applies
// Admin-only routes — both middlewares apply
{
path: "/master",
module: MasterLayout,
middlewares: [masterMiddleware],
children: [
{ path: "/workers", module: Workers }, // auth + master
{ path: "/settings", module: Settings },// auth + master
],
},
],
},
],
},
] satisfies AppRoutes;Middlewares accumulate from parent to child. In the example above, /master/workers runs authMiddleware first, then masterMiddleware. This applies to both page loads (loaders) and action calls.
Use Hono's c.get() / c.set():
// Middleware sets data
c.set("user", { id: "123", name: "Mauro", role: "master" });
// Loader reads it
export const loader = async ({ c }: LoaderArgs) => {
const user = c.get("user");
return { greeting: `Hello, ${user.name}` };
};
// Action reads it
export const action_save = async ({ body, c }: ActionArgs<{ name: string }>) => {
const user = c!.get("user");
// ...
};Register custom Hono routes before page/action routes. Call in app/server.tsx. Use this for REST APIs, SSE streams, file uploads, webhooks, and any custom HTTP endpoints.
// app/server.tsx
import { addRoutes } from "@mauroandre/velojs/server";
import type { Hono } from "hono";
addRoutes((app: Hono) => {
// REST API
app.get("/api/health", (c) => c.json({ ok: true }));
app.post("/api/upload", async (c) => {
const body = await c.req.parseBody();
const file = body.file;
// ...
return c.json({ ok: true });
});
// Middleware for a group of routes
app.use("/api/admin/*", async (c, next) => {
const token = c.req.header("Authorization");
if (!token) return c.json({ error: "Unauthorized" }, 401);
await next();
});
});Use Hono's streamSSE for real-time server-to-client communication.
import { addRoutes } from "@mauroandre/velojs/server";
addRoutes((app) => {
app.get("/api/events", async (c) => {
const { streamSSE } = await import("hono/streaming");
return streamSSE(c, async (stream) => {
// Send snapshot on connect
await stream.writeSSE({ event: "snapshot", data: JSON.stringify({ count: 0 }) });
// Subscribe to updates
const unsubscribe = subscribe((data) => {
stream.writeSSE({ event: "update", data: JSON.stringify(data) });
});
// Cleanup on disconnect
stream.onAbort(() => { unsubscribe(); });
// Keep stream open
await new Promise<void>(() => {});
});
});
});Client-side consumption with EventSource:
useEffect(() => {
const es = new EventSource("/api/events");
es.addEventListener("snapshot", (e) => {
state.value = JSON.parse(e.data);
});
es.addEventListener("update", (e) => {
state.value = JSON.parse(e.data);
});
return () => es.close();
}, []);addRoutes((app) => {
app.get("/api/metrics/live", async (c) => {
const { streamSSE } = await import("hono/streaming");
return streamSSE(c, async (stream) => {
let running = true;
stream.onAbort(() => { running = false; });
while (running) {
const metrics = await collectMetrics();
await stream.writeSSE({ data: JSON.stringify(metrics) });
await new Promise((r) => setTimeout(r, 3000));
}
});
});
});Access the underlying Node.js HTTP server. Useful for WebSocket handlers.
import { onServer } from "@mauroandre/velojs/server";
onServer((httpServer) => {
const { WebSocketServer } = await import("ws");
const wss = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", (req, socket, head) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
if (url.pathname === "/ws") {
wss.handleUpgrade(req, socket, head, (ws) => {
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
// Handle message
});
ws.on("close", () => {
// Cleanup
});
});
}
});
});Callbacks queue until the server starts. If called after startup, executes immediately.
| Variable | Default | Description |
|---|---|---|
SERVER_PORT |
3000 |
Server port |
NODE_ENV |
— | Set automatically by velojs start. Enables static file serving |
STATIC_BASE_URL |
"" |
CDN/bucket prefix for static assets |
veloPlugin() returns 6 plugins:
| Plugin | Purpose |
|---|---|
velo:config |
Build config (client/server modes, aliases, defines) |
velo:transform |
AST transforms (metadata injection, action stubs, loader removal) |
velo:static-url |
Rewrites CSS url(/path) to url(STATIC_BASE_URL/path) at build time |
@preact/preset-vite |
Preact JSX support |
@hono/vite-dev-server |
Dev server with SSR |
velo:ws-bridge |
Exposes Vite's HTTP server for WebSocket handlers in dev mode |
Applied during Vite's transform hook to files in appDirectory:
| # | Transform | When | What it does |
|---|---|---|---|
| 1 | injectMetadata |
Server + Client | Adds export const metadata = { moduleId, fullPath, path } |
| 2 | transformLoaderFunctions |
Server + Client | Injects moduleId: useLoader() → useLoader("moduleId") |
| 3 | transformActionsForClient |
Client only | Replaces action body with fetch() stub |
| 4 | removeLoaders |
Client only | Removes export const loader entirely |
| 5 | removeMiddlewares |
Client only | Removes middlewares: [...] and related imports |
velojs build
# 1. vite build → dist/client/ (client.js, client.css, manifest.json)
# 2. vite build --mode server → dist/server.js (SSR entry)| Module | Purpose |
|---|---|
virtual:velo/server-entry |
Server entry — imports server.tsx + routes, calls startServer() |
virtual:velo/client-entry |
Client entry — imports client.tsx + routes, calls startClient() |
/__velo_client.js |
Alias for client entry (used in dev) |
When routes.tsx changes, the plugin rebuilds the fullPath map and triggers a full page reload (not partial HMR).
VeloJS uses Node's AsyncLocalStorage to isolate data per request. Each SSR render runs in its own storage context, preventing data leaks between concurrent requests.
Hooks (useParams, useQuery, usePathname, Loader, useLoader) access this storage on the server via globalThis.__veloServerData.
| Import | Contents |
|---|---|
@mauroandre/velojs |
Types (AppRoutes, ActionArgs, LoaderArgs, Metadata), Scripts, Link, defineConfig |
@mauroandre/velojs/server |
startServer, createApp, addRoutes, onServer, serverDataStorage |
@mauroandre/velojs/client |
startClient |
@mauroandre/velojs/hooks |
Loader, useLoader, useParams, useQuery, useNavigate, usePathname, touch |
@mauroandre/velojs/cookie |
getCookie, setCookie, deleteCookie, getSignedCookie, setSignedCookie |
@mauroandre/velojs/factory |
createMiddleware, createFactory |
@mauroandre/velojs/vite |
veloPlugin |
@mauroandre/velojs/config |
defineConfig, VeloConfig |
interface LoaderArgs {
params: Record<string, string>;
query: Record<string, string>;
c: Context; // Hono Context
}
interface ActionArgs<TBody = unknown> {
body: TBody;
params?: Record<string, string>;
query?: Record<string, string>;
c?: Context;
}
interface Metadata {
moduleId: string;
fullPath?: string;
path?: string;
}
interface RouteModule {
Component: ComponentType<any>;
loader?: (args: LoaderArgs) => Promise<any>;
metadata?: Metadata;
[key: `action_${string}`]: (args: ActionArgs) => Promise<any>;
}
interface RouteNode {
path?: string;
module: RouteModule;
children?: RouteNode[];
middlewares?: MiddlewareHandler[];
isRoot?: boolean;
}
type AppRoutes = RouteNode[];
interface VeloConfig {
appDirectory?: string; // default: "./app"
routesFile?: string; // default: "routes.tsx"
serverInit?: string; // default: "server.tsx"
clientInit?: string; // default: "client.tsx"
}FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY app ./app
COPY tsconfig.json vite.config.ts ./
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV SERVER_PORT=3000
EXPOSE 3000
CMD ["npx", "velojs", "start"]velojs build
# dist/
# client/ # Static assets (JS, CSS, images)
# client.js
# client.css
# server.js # SSR server entry (single file)In production, velojs start sets NODE_ENV=production automatically and serves static files from dist/client/.
Set STATIC_BASE_URL to serve static assets from a CDN or S3 bucket:
STATIC_BASE_URL=https://cdn.example.com/assets node dist/server.jsThe <Scripts /> component and CSS url() references will use this prefix automatically.
VeloJS includes everything you need. A single npm install @mauroandre/velojs brings:
- Hono — HTTP server and routing
- Preact — UI rendering (SSR + client)
- @preact/signals — Reactive state management
- wouter-preact — Client-side routing
- Vite — Build tool and dev server
- @preact/preset-vite — Preact JSX support
- @hono/vite-dev-server — SSR dev server
No need to install these separately.