
A zero-boilerplate fullstack React framework for Val Town. Server-side render React apps with file-based loaders and actions, client-side hydration, and single-fetch navigation — all in a few lines of code.
Rendering mermaid diagram...
Create an HTTP val with a single file — components, loader, action, and routes all in one:
/** @jsxImportSource https://esm.sh/react@18.2.0 */
import { useLoaderData } from "https://esm.sh/react-router@7.5.0?deps=react@18.2.0,react-dom@18.2.0";
import { defineRoutes } from "https://esm.town/v/stevekrouse/vtrr/routes.ts";
import { createApp } from "https://esm.town/v/stevekrouse/vtrr/server.ts";
function Home() {
const { time } = useLoaderData() as { time: string };
return <h1>Server time: {time}</h1>;
}
export const loader = () => ({ time: new Date().toLocaleString() });
export const routes = defineRoutes([
{ path: "/", Component: Home, loader: import.meta.url },
]);
export default createApp({ routes });
That's it. SSR, hydration, and client-side navigation — all wired up automatically.
Rendering mermaid diagram...
| File | Purpose |
|---|---|
mod.ts | Public entry point — re-exports createApp, defineRoutes, and types |
server.ts | Hono server setup, SSR rendering, static file serving, hydration data injection |
routes.ts | defineRoutes() — transforms user route configs into React Router route objects (server: real imports, client: stubs) |
client-runtime.ts | Browser hydration script with single-fetch dataStrategy for client-side navigation |
types.ts | TypeScript interfaces (AppOptions, UserRouteConfig, re-exported RR types) |
Creates a (req: Request) => Promise<Response> handler. Export it as default from an HTTP val.
export default createApp({
routes, // from defineRoutes()
head: '<title>My App</title>', // extra <head> content
rootId: 'root', // DOM element ID (default: "root")
routesFile: './routes.tsx', // optional: explicit routes file path
setup: (app) => { /* add Hono middleware/API routes */ },
handler: (defaultHandler) => async (req) => { /* wrap the handler */ },
});
Converts your route config into React Router-compatible route objects. Loaders and actions are specified as string paths (resolved to dynamic imports on the server, stubbed on the client).
export const routes = defineRoutes([
{
path: "/",
Component: App,
children: [
{
index: true,
Component: Home,
loader: "./routes/Home.loader.ts", // string path!
},
{
path: "posts/:id",
Component: Post,
loader: "./routes/Post.loader.ts",
action: "./routes/Post.action.ts",
},
],
},
]);
Loader/action resolution:
- String paths (e.g.
"./routes/Home.loader.ts") — resolved relative toVALTOWN_ENTRYPOINTon the server; stubbed on the client import.meta.url— use when the loader is exported from the same file (great for single-file apps)
interface UserRouteConfig {
path?: string; // URL pattern
index?: boolean; // Index route flag
Component?: React.ComponentType; // React component
loader?: string; // Path to loader module
action?: string; // Path to action module
children?: UserRouteConfig[]; // Nested routes
errorElement?: React.ReactElement; // Error boundary
}
Client-side navigations use a single HTTP request per navigation instead of one request per loader. The client sends X-Data-Request: true header → the server runs all matched loaders → returns all data as one JSON response.
Rendering mermaid diagram...
📁 demos/hello-world.tsx — Single-file app with loader, action, and interactive counter.
📁 demos/message-board/ — Full multi-route app demonstrating:
demos/message-board/
├── index.tsx # Entry point, route definitions
├── types.ts # Shared TypeScript types
├── database/
│ ├── migrations.ts # SQLite schema setup
│ └── queries.ts # Database query functions
├── components/
│ ├── LoadingSpinner.tsx
│ ├── MessageForm.tsx
│ ├── MessageList.tsx
│ └── SearchForm.tsx
└── routes/
├── App.tsx # Root layout with <Outlet>
├── Home.tsx # Topic list page
├── Home.loader.ts # Loads topics from DB
├── Topic.tsx # Topic detail + messages
├── Topic.loader.ts
├── Topic.action.ts # Post new messages
├── TopicMessage.loader.ts
├── Topics.action.ts # Create new topics
├── Search.tsx
└── Search.loader.ts
- Hono for HTTP — lightweight, fast, and gives users an escape hatch via
setup()for API routes and middleware - String-based loader/action paths — loaders/actions never ship to the client; only the server dynamically imports them
- Single-fetch on navigation — one request per navigation instead of waterfall requests per loader
/__src/*file serving — project source files served under a prefix to avoid conflicts with page routes- Hydration via
window.__staticRouterHydrationData— server injects loader data, client picks it up seamlessly