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:
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:
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
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:
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
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:
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:
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
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:
// 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>© 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:
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:
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:
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:
// server/entry.ts
// ─── 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 |