bext.dev
DocsPRISM (bext-native)
PRISM (bext-native)8 min read
On this page

Nested Routing & Layouts

bext's router provides file-system-style nested routing with layout composition. Define routes declaratively with defineRoutes() or imperatively with createRouter(), then pass the router to createSite().

Architecture#

The router maps URL paths to page components and API handlers, wrapping pages in a stack of layout components from outermost to innermost:

code
Request: GET /blog/hello-world

Matched: /blog/[slug]

Layout composition:
  RootLayout          ← wraps everything
    └─ BlogLayout     ← wraps /blog/* pages
        └─ BlogPost   ← the page component

The router renders the page first, then wraps it in layouts from innermost to outermost. The final result is a complete HTML string.

createRouter() — imperative API#

Build a router step-by-step:

typescript
import { createRouter } from "@bext-stack/framework";
import type { RouteContext } from "@bext-stack/framework";

const router = createRouter();

// Root layout wraps all pages
router.layout(({ children, ctx }) => `
  <!DOCTYPE html>
  <html lang="en">
  <head><title>My App</title></head>
  <body>
    <nav><a href="/">Home</a> <a href="/blog">Blog</a></nav>
    <main>${children}</main>
  </body>
  </html>
`);

// Pages
router.page("/", (ctx) => `<h1>Welcome</h1><p>Hello, world!</p>`);
router.page("/about", (ctx) => `<h1>About</h1><p>About us.</p>`);

// Dynamic page
router.page("/blog/[slug]", (ctx) => {
  const slug = ctx.params.slug;
  const post = getPost(slug);
  return `<article><h1>${post.title}</h1>${post.html}</article>`;
});

// API route (no layout wrapping)
router.api("/api/health", (ctx) => ({
  status: 200,
  headers: [["content-type", "application/json"]],
  body: JSON.stringify({ ok: true }),
}));

Router methods#

typescript
class Router {
  /** Set the root layout (wraps all pages). */
  layout(component: LayoutComponent): this;

  /** Register a page at a URL pattern with optional extra layouts. */
  page(pattern: string, component: PageComponent, layouts?: LayoutComponent[]): this;

  /** Register an API handler (no layout wrapping). */
  api(pattern: string, handler: ApiHandler): this;

  /** Match a request and return the response. */
  handle(ctx: RouteContext): BextResponse | null;
}

defineRoutes() — declarative API#

For larger apps, defineRoutes() lets you declare the entire route tree as a nested object:

typescript
import { defineRoutes } from "@bext-stack/framework";

const router = defineRoutes({
  layout: RootLayout,
  routes: {
    "/": { page: HomePage },
    "/about": { page: AboutPage },
    "/blog": {
      layout: BlogLayout,
      page: BlogListPage,
      children: {
        "/[slug]": { page: BlogPostPage },
      },
    },
    "/docs": {
      layout: DocsLayout,
      children: {
        "/[...path]": { page: DocsPage },
      },
    },
    "/api/users": { handler: usersHandler },
    "/api/users/[id]": { handler: userByIdHandler },
  },
});

RouteTree and RouteNode types#

typescript
interface RouteTree {
  /** Root layout that wraps all pages. */
  layout?: LayoutComponent;
  /** Route definitions. Keys are URL patterns. */
  routes: Record<string, RouteNode>;
}

interface RouteNode {
  /** Page component for this route. */
  page?: PageComponent;
  /** API handler (mutually exclusive with page). */
  handler?: ApiHandler;
  /** Layout that wraps this route and its children. */
  layout?: LayoutComponent;
  /** Child routes (paths are concatenated with parent). */
  children?: Record<string, RouteNode>;
}

Dynamic segments#

Named parameters: [id]#

A segment wrapped in brackets matches any single URL segment and captures it as a parameter:

typescript
router.page("/users/[id]", (ctx) => {
  const userId = ctx.params.id;  // "42" for /users/42
  return `<h1>User ${userId}</h1>`;
});

router.page("/blog/[slug]", (ctx) => {
  const slug = ctx.params.slug;  // "hello-world" for /blog/hello-world
  return `<h1>${slug}</h1>`;
});

Catch-all: [...path]#

A catch-all segment matches the rest of the URL:

typescript
router.page("/docs/[...path]", (ctx) => {
  const docPath = ctx.params.path;  // "getting-started/installation" for /docs/getting-started/installation
  const content = loadDoc(docPath);
  return `<article>${content}</article>`;
});

Multiple parameters#

typescript
router.page("/repos/[owner]/[repo]", (ctx) => {
  const { owner, repo } = ctx.params;
  // owner = "benfavre", repo = "bext" for /repos/benfavre/bext
  return `<h1>${owner}/${repo}</h1>`;
});

Nested layouts#

Layouts receive children (the rendered inner content) and ctx (the route context). They wrap children in additional HTML:

typescript
import type { RouteContext } from "@bext-stack/framework";

// Root layout — HTML shell
function RootLayout({ children, ctx }: { children: string; ctx: RouteContext }) {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>My App</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <header>
    <nav>
      <a href="/">Home</a>
      <a href="/dashboard">Dashboard</a>
      <a href="/settings">Settings</a>
      ${ctx.authHtml}
    </nav>
  </header>
  ${children}
  <footer>&copy; 2026</footer>
</body>
</html>`;
}

// Dashboard layout — sidebar + content area
function DashboardLayout({ children, ctx }: { children: string; ctx: RouteContext }) {
  return `<div class="dashboard">
  <aside class="sidebar">
    <a href="/dashboard" class="${ctx.path === "/dashboard" ? "active" : ""}">Overview</a>
    <a href="/dashboard/analytics" class="${ctx.path === "/dashboard/analytics" ? "active" : ""}">Analytics</a>
    <a href="/dashboard/orders" class="${ctx.path === "/dashboard/orders" ? "active" : ""}">Orders</a>
  </aside>
  <main class="content">${children}</main>
</div>`;
}

// Settings layout — tabs
function SettingsLayout({ children, ctx }: { children: string; ctx: RouteContext }) {
  return `<div class="settings">
  <div class="tabs">
    <a href="/settings/profile" class="${ctx.path.startsWith("/settings/profile") ? "active" : ""}">Profile</a>
    <a href="/settings/billing" class="${ctx.path.startsWith("/settings/billing") ? "active" : ""}">Billing</a>
    <a href="/settings/team" class="${ctx.path.startsWith("/settings/team") ? "active" : ""}">Team</a>
  </div>
  <div class="tab-content">${children}</div>
</div>`;
}

API routes#

API routes use handler instead of page. They return a BextResponse directly and are never wrapped in layouts:

typescript
import { json, notFound } from "@bext-stack/framework";
import type { RouteContext, BextResponse } from "@bext-stack/framework";

function usersHandler(ctx: RouteContext): BextResponse | null {
  if (ctx.request.method === "GET") {
    const users = getAllUsers();
    return json(users);
  }
  if (ctx.request.method === "POST") {
    const body = JSON.parse(ctx.request.body ?? "{}");
    const user = createUser(body);
    return json(user, 201);
  }
  return json({ error: "Method not allowed" }, 405);
}

function userByIdHandler(ctx: RouteContext): BextResponse | null {
  const id = ctx.params.id;
  const user = getUserById(id);
  if (!user) return notFound("User not found");
  return json(user);
}

Integration with createSite()#

Pass the router to createSite() via the router option:

typescript
import { createSite } from "@bext-stack/framework/entry";
import { defineRoutes } from "@bext-stack/framework";

const router = defineRoutes({
  layout: RootLayout,
  routes: {
    "/": { page: HomePage },
    "/about": { page: AboutPage },
    "/blog": {
      layout: BlogLayout,
      page: BlogListPage,
      children: {
        "/[slug]": { page: BlogPostPage },
      },
    },
    "/api/health": { handler: healthHandler },
  },
});

createSite({
  hostname: "my-app.bext.dev",
  router,
});

When a router is configured, it takes priority over pages and routes. The processing order is: SEO routes, router, flat routes, static pages.

You can also use a router alongside static pages for a hybrid approach:

typescript
createSite({
  pages,              // Static content pages
  template,           // Template for static pages
  hostname: "my-app.bext.dev",
  router,             // Dynamic routes with layouts
  routes: [redirects], // Legacy redirects
});

Full example: dashboard app#

A complete dashboard application with nested layouts:

typescript
// server/entry.ts
import { createSite } from "@bext-stack/framework/entry";
import { defineRoutes, json, redirect } from "@bext-stack/framework";
import type { RouteContext, BextResponse } from "@bext-stack/framework";

// ─── Layouts ───────────────────────────────────────────────────────────────

function RootLayout({ children, ctx }: { children: string; ctx: RouteContext }) {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Acme Dashboard</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <header class="topbar">
    <a href="/" class="logo">Acme</a>
    <nav>
      <a href="/dashboard">Dashboard</a>
      <a href="/settings/profile">Settings</a>
    </nav>
    ${ctx.authHtml}
  </header>
  ${children}
</body>
</html>`;
}

function DashboardLayout({ children, ctx }: { children: string; ctx: RouteContext }) {
  return `<div class="dash-shell">
  <aside class="dash-nav">
    <a href="/dashboard" class="${ctx.path === "/dashboard" ? "active" : ""}">Overview</a>
    <a href="/dashboard/analytics" class="${ctx.path === "/dashboard/analytics" ? "active" : ""}">Analytics</a>
    <a href="/dashboard/orders" class="${ctx.path.startsWith("/dashboard/orders") ? "active" : ""}">Orders</a>
    <a href="/dashboard/customers" class="${ctx.path === "/dashboard/customers" ? "active" : ""}">Customers</a>
  </aside>
  <main class="dash-content">${children}</main>
</div>`;
}

function SettingsLayout({ children, ctx }: { children: string; ctx: RouteContext }) {
  return `<div class="settings-shell">
  <div class="settings-tabs">
    <a href="/settings/profile" class="${ctx.path === "/settings/profile" ? "active" : ""}">Profile</a>
    <a href="/settings/billing" class="${ctx.path === "/settings/billing" ? "active" : ""}">Billing</a>
    <a href="/settings/team" class="${ctx.path === "/settings/team" ? "active" : ""}">Team</a>
  </div>
  <div class="settings-body">${children}</div>
</div>`;
}

// ─── Pages ─────────────────────────────────────────────────────────────────

const HomePage = (ctx: RouteContext) =>
  `<section class="hero"><h1>Welcome to Acme</h1><p>Your all-in-one business platform.</p></section>`;

const DashOverview = (ctx: RouteContext) =>
  `<h1>Overview</h1><div class="stats-grid">
    <div class="stat"><strong>$12,340</strong><span>Revenue</span></div>
    <div class="stat"><strong>342</strong><span>Orders</span></div>
    <div class="stat"><strong>89</strong><span>Customers</span></div>
  </div>`;

const DashAnalytics = (ctx: RouteContext) =>
  `<h1>Analytics</h1><p>Charts and metrics go here.</p>`;

const DashOrders = (ctx: RouteContext) =>
  `<h1>Orders</h1><p>Recent orders listed here.</p>`;

const DashOrderDetail = (ctx: RouteContext) =>
  `<h1>Order #${ctx.params.id}</h1><p>Order details for ${ctx.params.id}.</p>`;

const DashCustomers = (ctx: RouteContext) =>
  `<h1>Customers</h1><p>Customer list here.</p>`;

const SettingsProfile = (ctx: RouteContext) =>
  `<h1>Profile Settings</h1><form><label>Name<input value="${ctx.user?.firstName ?? ""}"></label></form>`;

const SettingsBilling = (ctx: RouteContext) =>
  `<h1>Billing</h1><p>Manage your subscription.</p>`;

const SettingsTeam = (ctx: RouteContext) =>
  `<h1>Team Members</h1><p>Invite and manage team members.</p>`;

// ─── API ───────────────────────────────────────────────────────────────────

function ordersApi(ctx: RouteContext): BextResponse | null {
  if (ctx.request.method === "GET") {
    return json([
      { id: "ORD-001", total: 99.99, status: "shipped" },
      { id: "ORD-002", total: 249.00, status: "processing" },
    ]);
  }
  return json({ error: "Method not allowed" }, 405);
}

// ─── Router ────────────────────────────────────────────────────────────────

const router = defineRoutes({
  layout: RootLayout,
  routes: {
    "/": { page: HomePage },
    "/dashboard": {
      layout: DashboardLayout,
      page: DashOverview,
      children: {
        "/analytics": { page: DashAnalytics },
        "/orders": { page: DashOrders },
        "/orders/[id]": { page: DashOrderDetail },
        "/customers": { page: DashCustomers },
      },
    },
    "/settings": {
      layout: SettingsLayout,
      children: {
        "/profile": { page: SettingsProfile },
        "/billing": { page: SettingsBilling },
        "/team": { page: SettingsTeam },
      },
    },
    "/api/orders": { handler: ordersApi },
  },
});

// ─── Site ──────────────────────────────────────────────────────────────────

createSite({
  hostname: "acme.bext.dev",
  router,
  authHtml: (user) =>
    user
      ? `<span class="user">${user.firstName}</span>`
      : `<a href="/login">Sign in</a>`,
});

Route table for this example#

URL Layout stack Component
/ RootLayout HomePage
/dashboard RootLayout > DashboardLayout DashOverview
/dashboard/analytics RootLayout > DashboardLayout DashAnalytics
/dashboard/orders RootLayout > DashboardLayout DashOrders
/dashboard/orders/ORD-001 RootLayout > DashboardLayout DashOrderDetail
/dashboard/customers RootLayout > DashboardLayout DashCustomers
/settings/profile RootLayout > SettingsLayout SettingsProfile
/settings/billing RootLayout > SettingsLayout SettingsBilling
/settings/team RootLayout > SettingsLayout SettingsTeam
/api/orders (none) ordersApi
Tip

API routes (registered with handler rather than page) are never wrapped in layouts and receive no HTML shell. Return a BextResponse directly using the json(), html(), or redirect() helpers.

Note

When a router is configured via createSite(), it takes priority over the flat routes array and pages module. Use router for new projects and reserve flat routes for legacy redirects only.

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.