Skip to content

mauro-andre/velojs

Repository files navigation

VeloJS

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)

Getting Started

Create a new project

npx @mauroandre/velojs init my-app
cd my-app
npm install
npx velojs dev

Project structure

my-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

vite.config.ts

import { defineConfig } from "vite";
import { veloPlugin } from "@mauroandre/velojs/vite";

export default defineConfig({
    plugins: [veloPlugin()],
});

package.json scripts

{
    "scripts": {
        "dev": "velojs dev",
        "build": "velojs build",
        "start": "velojs start"
    }
}

app/client-root.tsx — Root component

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>
);

app/client.tsx — Client entry

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();

app/server.tsx — Server entry

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);

app/routes.tsx — Route definitions

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;

app/pages/Home.tsx — First page

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>;
};

Run

npm run dev     # http://localhost:3000

Configuration

veloPlugin({
    appDirectory: "./app",      // default
    routesFile: "routes.tsx",   // default
    serverInit: "server.tsx",   // default
    clientInit: "client.tsx",   // default
});

Routes

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;

Component nesting

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.

Route Node Properties

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>)

Path resolution

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.

Shared layouts, different paths

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;

Components

Conventions

Export Purpose
export const Component Preact component (required)
export const loader Server-side data loader
export const action_* Server-side actions (RPC)

Example Page

// 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>
    );
};

Server-only imports

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.


Loaders

Two patterns for consuming loader data:

useLoader<T>() — Component-level (SSR + SPA)

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]);

Loader<T>() — Module-level (SSR only)

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>
);

Data Flow

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)

Actions

Server-side functions callable from the client via RPC.

Definition

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 };
};

Client-Side Behavior

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.


Hooks

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

touch

const items = useSignal<Item[]>([]);

// Mutating nested properties doesn't trigger signal updates
items.value[0].checked = true;

// touch() forces the update
touch(items);

Link Component

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>

The ~/ prefix

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 ~/.

Props

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)

Scripts Component

Injects necessary scripts and styles in <head>.

import { Scripts } from "@mauroandre/velojs";

export const Component = ({ children }) => (
    <html>
        <head>
            <Scripts />
        </head>
        <body>{children}</body>
    </html>
);

Props

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

Output

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>

Middlewares

Server-side only. Removed from client bundle at build time.

Creating a middleware

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();
});

Using in routes

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;

Inheritance

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.

Accessing middleware data in loaders and actions

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");
    // ...
};

Server API

addRoutes(fn)

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();
    });
});

Server-Sent Events (SSE)

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();
}, []);

SSE with polling (live metrics)

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));
            }
        });
    });
});

onServer(fn)

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.

Environment Variables

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

Vite Plugin Architecture

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

AST Transformations

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

Build Process

velojs build
# 1. vite build              → dist/client/ (client.js, client.css, manifest.json)
# 2. vite build --mode server → dist/server.js (SSR entry)

Virtual Modules

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)

Hot Reload

When routes.tsx changes, the plugin rebuilds the fullPath map and triggers a full page reload (not partial HMR).


Request Isolation

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.


Subpath Exports

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

Type Reference

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"
}

Docker / Production Deploy

Dockerfile

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"]

Build output

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/.

Static assets on CDN

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.js

The <Scripts /> component and CSS url() references will use this prefix automatically.


Included Dependencies

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.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages