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:
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#
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:
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#
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:
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>© 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:
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:
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:
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
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 |
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.
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.
Related#
- PRISM Framework — file-system routing built into PRISM (alternative to
defineRoutes) - PRISM Data Conventions — loader/action co-location with route files
- Template Framework —
createSite()and the fullSiteConfigtype - Islands Architecture — adding client interactivity to pages defined with this router
- Guides: Routing — advanced patterns, middleware, and redirects