Routage et mises en page imbriqués
Le routeur du bext fournit un routage imbriqué de type système de fichiers avec une composition de mise en page. Définissez les routes de manière déclarative avec defineRoutes() ou impérativement avec createRouter(), puis passez le routeur à createSite().
Architecture#
Le routeur mappe les chemins d'URL vers les composants de page et les gestionnaires d'API, en encapsulant les pages dans une pile de composants de mise en page du plus externe au plus interne :
Request: GET /blog/hello-world
Matched: /blog/[slug]
Layout composition:
RootLayout ← wraps everything
└─ BlogLayout ← wraps /blog/* pages
└─ BlogPost ← the page component
Le routeur restitue d'abord la page, puis l'enveloppe dans des mises en page allant du plus interne au plus externe. Le résultat final est une chaîne HTML complète.
createRouter() — API impérative#
Construisez un routeur étape par étape :
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 }),
}));
Méthodes de routeur#
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() — API déclarative#
Pour les applications plus volumineuses, defineRoutes() vous permet de déclarer l'intégralité de l'arborescence des routes en tant qu'objet imbriqué :
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 },
},
});
Types RouteTree et RouteNode#
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>;
}
Segments dynamiques#
Paramètres nommés : [id]#
Un segment entre parenthèses correspond à n'importe quel segment d'URL et le capture en tant que paramètre :
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>`;
});
Fourre-tout : [...path]#
Un segment fourre-tout correspond au reste de l'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>`;
});
Paramètres multiples#
router.page("/repos/[owner]/[repo]", (ctx) => {
const { owner, repo } = ctx.params;
// owner = "benfavre", repo = "bext" for /repos/benfavre/bext
return `<h1>${owner}/${repo}</h1>`;
});
Dispositions imbriquées#
Les mises en page reçoivent children (le contenu interne rendu) et ctx (le contexte de l'itinéraire). Ils enveloppent les enfants dans du HTML supplémentaire :
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>`;
}
Itinéraires API#
Les routes API utilisent handler au lieu de page. Ils renvoient directement un BextResponse et ne sont jamais enveloppés dans des mises en page :
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);
}
Intégration avec createSite()#
Passez le routeur à createSite() via l'option router :
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,
});
Lorsqu'un routeur est configuré, il est prioritaire sur pages et routes. L'ordre de traitement est le suivant : itinéraires SEO, routeur, itinéraires plats, pages statiques.
Vous pouvez également utiliser un routeur avec des pages statiques pour une approche hybride :
createSite({
pages, // Static content pages
template, // Template for static pages
hostname: "my-app.bext.dev",
router, // Dynamic routes with layouts
routes: [redirects], // Legacy redirects
});
Exemple complet : application de tableau de bord#
Une application de tableau de bord complète avec des mises en page imbriquées :
// 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>`,
});
Table de routage pour cet exemple#
| URL | Pile de mise en page | Composant |
|---|---|---|
/ |
Disposition racine | Page d'accueil |
/dashboard |
RootLayout > Tableau de bordLayout | DashVue d'ensemble |
/dashboard/analytics |
RootLayout > Tableau de bordLayout | DashAnalytics |
/dashboard/orders |
RootLayout > Tableau de bordLayout | Commandes Dash |
/dashboard/orders/ORD-001 |
RootLayout > Tableau de bordLayout | DashOrderDétail |
/dashboard/customers |
RootLayout > Tableau de bordLayout | Clients Dash |
/settings/profile |
RootLayout > ParamètresLayout | ParamètresProfil |
/settings/billing |
RootLayout > ParamètresLayout | ParamètresFacturation |
/settings/team |
RootLayout > ParamètresLayout | ParamètresÉquipe |
/api/orders |
(aucun) | commandesApi |
Les routes API (enregistrées avec handler plutôt que page ) ne sont jamais encapsulées dans des mises en page et ne reçoivent aucun shell HTML. Renvoyez un BextResponse directement à l’aide des assistants json(), html() ou redirect().
Lorsqu'un router est configuré via createSite(), il est prioritaire sur le tableau plat routes et
Module pages. Utilisez router pour les nouveaux projets et réservez des itinéraires plats uniquement pour les anciennes redirections.
En rapport#
- Cadre PRISM — routage du système de fichiers intégré à PRISM (alternative à
defineRoutes) - Conventions de données PRISM — colocalisation chargeur/action avec fichiers de route
- Cadre de modèle —
createSite()et le typeSiteConfigcomplet - Architecture des îles — ajout de l'interactivité client aux pages définies avec ce routeur
- Guides : routage — modèles avancés, middleware et redirections