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. Routerconfig.router.handle(ctx) (if a router is configured) 3. Flat routes — each config.routes[] handler, first non-null wins 4. Static pagesconfig.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>&copy; 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.txtUser-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>`,
});