pracht resolves your routes, loaders, API routes, and capabilities into one explicit graph — then projects it to browsers and to agents: HTTP endpoints, WebMCP page tools, remote MCP, and llms.txt. Most frameworks render your app for humans and leave agents to scrape it.
src/routes.ts
import { defineApp, group, route, timeRevalidate } from "@pracht/core";
export const app = defineApp({
shells: {
public: "./shells/public.tsx",
app: "./shells/app.tsx",
},
middleware: { auth: "./middleware/auth.ts" },
// The same graph an agent sees: typed operations, not a scraped DOM.
capabilities: { "notes.search": "./capabilities/notes-search.ts" },
routes: [
group({ shell: "public" }, [
route("/", "./routes/home.tsx", { render: "ssg" }),
route("/pricing", "./routes/pricing.tsx", {
render: "isg", revalidate: timeRevalidate(3600),
}),
]),
group({ shell: "app", middleware: ["auth"] }, [
route("/dashboard", "./routes/dashboard.tsx", { render: "ssr" }),
route("/settings", "./routes/settings.tsx", { render: "spa" }),
]),
],
});Measured, not claimed
Hydration is a per-route setting, so the framework's cost is something you choose rather than something you inherit. Each rung below is the same page, rendering the same markup, with one thing changed.
hydration: "none"0 KBStatic HTML. No client runtime is injected at all.
hydration: "islands"7.5 KBPreact plus the island bootstrap. Only components in src/islands/ hydrate — the router never loads.
hydration: "full"17.4 KBThe page hydrates and the client router takes over navigation, prefetching, and loader fetches.
full + preact/compat18.2 KBThe same page with the React compatibility layer, so React-authored dependencies resolve.
Gzipped client JavaScript a cold load fetches, including the chunks the router imports after hydration. Your application code sits on top of this. Switching prefetching off with client: { prefetch: false } takes full hydration to 15.9 KB. Re-measure any of it with pnpm bench — how these numbers are produced.
Why pracht
Everything below follows from the graph being explicit. A manifest a machine can read is a manifest a machine can serve, review, and test.
Routes, loaders, API routes, and capabilities are declared in one typed manifest and resolved into a single graph. Nothing is inferred from folder names, so what runs where is readable by you, by a reviewer, and by a machine.
The same graph becomes typed HTTP endpoints, WebMCP page tools, remote MCP tools, and an llms.txt index. Agents call declared operations with validated input instead of guessing at your DOM.
Validation, middleware, effect class, and confirmation run on the server for every caller — your loader, the browser, an in-page agent, a remote MCP host. The human UI and the agent surface cannot drift, because they are the same function.
Full hooks, JSX, and the Preact ecosystem on a runtime you can size: 0 KB on a static route, 17.4 KB gzip fully hydrated. Both measured by pnpm bench, both gated in CI.
SSG, SSR, ISG, or SPA, and full, islands, or no hydration — chosen per route. Mix static marketing pages with dynamic dashboards in one app and one build.
Full Vite pipeline for client and SSR builds, plus loader return types that flow into components and capability contracts that flow into every call site.
And it deploys anywhere: Node, Cloudflare Workers, Netlify, Vercel, or a pure static export, from one codebase and one build. A thin adapter, not a rewrite.
Rendering
Configure render mode per route. Mix and match in the same app without extra wiring or separate deployments.
HTML at build time. Serve from CDN with zero server cost. Perfect for marketing pages, blogs, and docs.
Fresh HTML on every request. Full access to cookies, headers, and auth state. Ideal for personalized pages.
Static HTML that regenerates on a schedule. Serve instantly, update in the background. Great for catalogs and pricing.
No SSR — render entirely in the browser. Best for auth-gated dashboards where SEO doesn't matter.
Data Loading
Loader functions run server-side only — during the build for SSG, on each request for SSR. Secrets, database connections, and API keys never reach the client bundle.
After hydration, client navigation fetches only the loader data as JSON — the component tree updates without a full page reload.
Data loading guideimport type { LoaderArgs, RouteComponentProps } from "@pracht/core";
export async function loader({ request, context }: LoaderArgs) {
const user = await getUser(request);
return { user, projects: await context.db.projects.all() };
}
export function head({ data }) {
return { title: `${data.user.name} — Dashboard` };
}
export function Component({ data }: RouteComponentProps<typeof loader>) {
// data is typed: { user: User; projects: Project[] }
return <h1>Welcome, {data.user.name}</h1>;
}Get Started
Install pracht and the Vite plugin, wire up your adapter, and ship to Cloudflare Workers or Vercel in minutes.