Template Framework
The @bext-stack/framework package is bext's built-in framework for building server-rendered sites. It renders pages as pure HTML strings — no React, no Virtual DOM, no hydration. Pages load instantly because they ship zero client-side JavaScript by default.
Every bext site (docs.bext.dev, blog.bext.dev, bext.dev itself) is built with this framework.
How it works
1. You define pages (content) and a template (layout shell)
2. createSite() wires them together and registers a globalThis.__fetch handler
3. bext's Rust server calls __fetch from its V8 worker pool for every request
4. The response is a JSON-serialized BextResponse with status, headers, and body
There is no build step required — bext bundles your TypeScript with Bun and evaluates it in V8. But you can add a build step for Tailwind CSS or other preprocessing.
Installation
The framework ships as a local package in your bext project. Reference it via path alias:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@bext-stack/framework/*": ["../shared/framework/*"],
"@bext-stack/framework": ["../shared/framework/src/index.ts"]
}
}
}
createSite() API
The entry point for every template site. Call it in your server/entry.ts:
createSite({
pages,
template,
hostname: "my-site.bext.dev",
});
Full config options
interface SiteConfig {
/** Page content provider (getPage, getPagePaths). */
pages?: PagesModule;
/** Template that wraps pages in an HTML shell. */
template?: TemplateModule;
/** Hostname for SEO generation and URL resolution. */
hostname: string;
/** Flat route handlers (legacy). First non-null response wins. */
routes?: RouteHandler[];
/** File-based router with nested layouts (preferred over flat routes). */
router?: { handle(ctx: RouteContext): BextResponse | null };
/** SEO config for robots.txt and sitemap.xml. Set to false to disable. */
seo?: SeoConfig | false;
/** Default ISR cache TTL in milliseconds. */
cacheTtlMs?: number;
/** Generate auth HTML (login/logout buttons) from the current user. */
authHtml?: (user: AuthUser | null) => string;
/** Initialization function called once at startup. */
init?: () => void;
}
Request processing order
When a request arrives, createSite processes it in this order:
1. SEO routes — /robots.txt and /sitemap.xml (if seo is not false)
2. Router — config.router.handle(ctx) (if a router is configured)
3. Flat routes — each config.routes[] handler, first non-null wins
4. Static pages — config.pages.getPage(path) rendered through config.template
5. 404 — fallback not-found page
pages.ts contract
The pages module provides content for your site. It must export getPage() and optionally getPagePaths():
const pages: PageMap = {
"/": {
title: "Home",
description: "Welcome to my site",
html: "<h1>Hello World</h1><p>This is my site.</p>",
},
"/about": {
title: "About",
description: "About us",
html: "<h1>About</h1><p>We build things.</p>",
},
"/contact": {
title: "Contact",
description: "Get in touch",
html: "<h1>Contact</h1><p>Email us at hello@example.com</p>",
},
};
export function getPage(path: string): Page | null {
return pages[path] ?? null;
}
export function getPagePaths(): string[] {
return Object.keys(pages);
}
Page type
interface Page {
title: string;
description: string;
html: string; // Raw HTML content (no layout wrapper)
}
template.ts contract
The template module wraps page content in a full HTML document shell:
export function renderPage(page: Page, ctx: RenderContext): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${page.title} — My Site</title>
<meta name="description" content="${page.description}">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<nav>
<a href="/" class="${ctx.path === "/" ? "active" : ""}">Home</a>
<a href="/about" class="${ctx.path === "/about" ? "active" : ""}">About</a>
<a href="/contact" class="${ctx.path === "/contact" ? "active" : ""}">Contact</a>
${ctx.authHtml}
</nav>
<main>${page.html}</main>
<footer>© 2026 My Site</footer>
</body>
</html>`;
}
RenderContext type
interface RenderContext {
/** Current request path (e.g., "/about"). */
path: string;
/** Pre-rendered auth HTML (login/logout buttons). */
authHtml: string;
/** Authenticated user, or null for anonymous visitors. */
user: AuthUser | null;
/** Full request object with headers, method, body, etc. */
request: BextRequest;
}
Route handlers
For dynamic routes (APIs, form submissions, redirects), use route handlers:
const apiProducts: RouteHandler = (ctx) => {
if (ctx.path !== "/api/products") return null;
if (ctx.request.method === "GET") {
return json([
{ id: 1, name: "Widget" },
{ id: 2, name: "Gadget" },
]);
}
if (ctx.request.method === "POST") {
const body = JSON.parse(ctx.request.body ?? "{}");
return json({ created: true, name: body.name }, 201);
}
return null;
};
const legacyRedirects: RouteHandler = (ctx) => {
if (ctx.path === "/old-page") return redirect("/new-page", 301);
if (ctx.path === "/legacy") return redirect("/");
return null;
};
Wire them into your site:
createSite({
pages,
template,
hostname: "my-site.bext.dev",
routes: [apiProducts, legacyRedirects],
});
RouteContext type
interface RouteContext {
request: BextRequest;
path: string;
url: URL;
user: AuthUser | null;
authHtml: string;
query: URLSearchParams;
params: Record<string, string>;
}
BextRequest type
interface BextRequest {
method: string;
url: string;
pathname: string;
headers: [string, string][];
body: string | null;
tenant_id: string | null;
site_id: string | null;
auth_user: AuthUser | null;
client_ip: string | null;
locale: string | null;
is_bot: boolean;
}
Response helpers
The framework provides factory functions for common response types:
// JSON response (200 by default)
json({ message: "ok" })
json({ error: "not found" }, 404)
// HTML response
html("<h1>Hello</h1>")
html("<h1>Error</h1>", 500)
// Plain text
text("Hello, world")
// XML (for feeds, APIs)
xml("<rss>...</rss>")
// Redirects (302 by default, supports 301/307/308)
redirect("/new-location")
redirect("/permanent", 301)
// 404 with optional body
notFound()
notFound("Custom not found message")
// Wrap any response with ISR cache hints
cached(json({ data: "expensive" }), 60_000) // 60s TTL
cached(html("<h1>Cached</h1>"), 3600_000, ["page:home"]) // 1h TTL with cache tags
BextResponse type
interface BextResponse {
status: number;
headers: [string, string][];
body: string;
cache?: CacheHint;
}
interface CacheHint {
enabled: boolean;
ttl_ms?: number; // Time-to-live in milliseconds
swr_ms?: number; // Stale-while-revalidate window
tags?: string[]; // Cache tags for targeted invalidation
}
SEO auto-generation
When seo is configured (or defaults to { hostname } when not explicitly set), bext automatically generates:
- /robots.txt — User-agent: *, Allow: /, sitemap reference, and optional Disallow paths
- /sitemap.xml — all page paths from getPagePaths() plus any additionalPaths
createSite({
pages,
template,
hostname: "my-site.bext.dev",
seo: {
hostname: "my-site.bext.dev",
additionalPaths: ["/api/feed"], // Extra URLs for the sitemap
disallowPaths: ["/admin", "/api"], // Block from robots.txt
},
});
To disable SEO generation entirely:
createSite({
// ...
seo: false,
});
Minimal site example (3 files)
A complete bext template site in three files:
server/entry.ts
createSite({
pages,
template,
hostname: "hello.bext.dev",
});
server/pages.ts
const content: Record<string, Page> = {
"/": {
title: "Home",
description: "A minimal bext site",
html: "<h1>Hello from bext</h1><p>This site renders in under 50 microseconds.</p>",
},
"/about": {
title: "About",
description: "About this site",
html: "<h1>About</h1><p>Built with @bext-stack/framework — zero JavaScript shipped to the browser.</p>",
},
};
export function getPage(path: string): Page | null {
return content[path] ?? null;
}
export function getPagePaths(): string[] {
return Object.keys(content);
}
server/template.ts
export function renderPage(page: Page, ctx: RenderContext): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${page.title}</title>
<meta name="description" content="${page.description}">
</head>
<body>
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
<main>${page.html}</main>
</body>
</html>`;
}
Complex site with routes
A site with API routes, auth, caching, and redirects:
// API: list products from SQLite
const productsApi: RouteHandler = (ctx) => {
if (!ctx.path.startsWith("/api/products")) return null;
if (ctx.path === "/api/products" && ctx.request.method === "GET") {
const { rows } = dbQuery("./data.db", "SELECT * FROM products LIMIT 50");
return cached(json(rows), 30_000, ["products"]);
}
// Single product
const match = ctx.path.match(/^\/api\/products\/(\d+)$/);
if (match) {
const { rows } = dbQuery("./data.db", "SELECT * FROM products WHERE id = ?", [match[1]]);
if (!rows?.length) return notFound("Product not found");
return json(rows[0]);
}
return null;
};
// Auth-gated admin
const adminRoute: RouteHandler = (ctx) => {
if (!ctx.path.startsWith("/admin")) return null;
if (!ctx.user) return redirect("/login");
if (!ctx.user.isSuperAdmin) return json({ error: "Forbidden" }, 403);
return html("<h1>Admin Dashboard</h1><p>Welcome, " + ctx.user.firstName + "</p>");
};
// Legacy URL redirects
const redirects: RouteHandler = (ctx) => {
const map: Record<string, string> = {
"/old-pricing": "/pricing",
"/v1/docs": "/docs",
};
const target = map[ctx.path];
return target ? redirect(target, 301) : null;
};
createSite({
pages,
template,
hostname: "my-app.bext.dev",
routes: [productsApi, adminRoute, redirects],
cacheTtlMs: 60_000,
authHtml: (user) =>
user
? `<span>Hi, ${user.firstName}</span> <a href="/logout">Logout</a>`
: `<a href="/login">Login</a>`,
});