Routing & layouts
In Kyln the filesystem is the router. Every file under src/routes/ becomes a URL, and a handful of underscore-prefixed filenames are conventions that shape how those URLs render.
Pages
A page is a class that extends Route and returns markup from render(). The file's path determines its URL.
// src/routes/index.route.ts → /
import { Route, html } from "@kyln/core";
export default class Index extends Route {
render() {
return html`<h1>Home</h1>`;
}
}The .route suffix is optional and is stripped when building the URL. about.route.ts and about.ts both serve /about; the suffix is a convention that makes route files easy to spot.
| File | URL |
|---|---|
src/routes/index.route.ts |
/ |
src/routes/about.route.ts |
/about |
src/routes/blog/index.route.ts |
/blog |
src/routes/blog/archive.route.ts |
/blog/archive |
Folders become path segments. Nesting files under blog/ nests them under /blog.
Dynamic segments
Wrap a filename segment in square brackets to capture it as a parameter:
// src/routes/tasks/[id].route.ts → /tasks/42
import { Route, html } from "@kyln/core";
export default class Task extends Route {
render() {
return html`<h1>Task ${this.params.id}</h1>`;
}
}Captured segments arrive on this.params, keyed by the bracket name: /tasks/42 renders with this.params.id equal to "42", /tasks/99 with "99". Params are always strings.
Loading data
A page that depends on data has two tools, split by where the data should be fetched.
load() runs before render. Give the route a load(params) method and it runs ahead of render(), with its return value arriving as this.data. On the first request that happens on the server, so the initial HTML ships with the data already in place:
// src/routes/blog/[slug].route.ts
import { Route, html } from "@kyln/core";
const POSTS: Record<string, { title: string; body: string }> = {
"hello-kyln": { title: "Hello, Kyln", body: "The first post." },
};
export default class BlogPost extends Route {
load(params: Record<string, string>) {
return POSTS[params.slug] ?? null;
}
render() {
return html`<h1>${this.data?.title ?? "Not found"}</h1>`;
}
}load() may be async: swap the lookup for any await and render still waits for the result, so the first paint arrives complete.
One constraint to know: load() runs on the server for the first request and in the browser on client-side navigations, so whatever it touches must work in both places. Fetching an absolute URL qualifies. A relative fetch to your own /api does not; the server has no base URL to resolve it against. Data from your own API belongs in onMount(), below.
onMount() runs in the browser after the page is up. Fetch there when the data is dynamic client state rather than first-paint content, holding it in a signal and rendering a placeholder until it lands. This is the pattern the scaffolded task tracker uses:
import { Route, signal, html } from "@kyln/core";
export default class Tasks extends Route {
private tasks = signal<Task[]>([]);
private loading = signal(true);
onMount() {
this.loadTasks();
}
private async loadTasks() {
const res = await fetch("/api/tasks");
if (res.ok) this.tasks.set(await res.json());
this.loading.set(false);
}
render() {
return html`
<div>
${this.loading()
? html`<p>Loading...</p>`
: this.tasks().map((t) => html`<p>${t.title}</p>`)}
</div>
`;
}
}Rule of thumb: load() when the content should be in the first paint (posts, docs, product pages), onMount() when it's live application state behind a loading placeholder.
Catch-all routes
A [...name] segment matches any number of trailing path segments. This is how the starter app implements its 404 page:
// src/routes/[...catchall].route.ts → any unmatched path
import { Route, html } from "@kyln/core";
export default class Catchall extends Route {
render() {
return html`
<div>
<h1>404</h1>
<p>This page doesn't exist.</p>
<a href="/">← Back to home</a>
</div>
`;
}
}Server-only pages
Naming a file *.server.route.ts makes it a server-only page:
// src/routes/docs/[...slug].server.route.ts → /docs/*, rendered on the serverA server-only page renders entirely on the server and is excluded from the client bundle. In production it is prerendered at bake time where possible and server-rendered otherwise; either way, none of its code ships to the browser. Navigating to one is a full page load: the client router hands these URLs to the server instead of handling them in-page.
That placement changes what the page can do:
- It can import server-only modules. File system access, a database client, a syntax highlighter: dependencies that could never run in the browser are fine here, because the file is never bundled for it.
- It carries no client JavaScript. The browser receives finished HTML, which is the right shape for content-heavy pages where SEO and fast first paint matter more than interactivity.
Choose .route.ts when the page has interactive client state: signals updating the DOM, event handlers, anything that runs after hydration. Choose .server.route.ts when the page is content the server can finish rendering on its own.
Everything else in this guide applies to both kinds of page: _layout files wrap server-only pages the same way, and _guard protection covers them since guards are enforced on the server. For how this split decides where you set breakpoints, see "Where your code runs" in Debugging.
Layouts
A _layout.ts file wraps every page in its directory and below. It extends Layout and receives the page's rendered output as content:
// src/routes/_layout.ts
import { Layout, html, setHead } from "@kyln/core";
export default class AppLayout extends Layout {
render(content: Node) {
setHead({
title: "My App",
link: [{ rel: "stylesheet", href: "/styles.css" }],
});
return html`
<div class="app">
<nav>My App</nav>
<main>${content}</main>
</div>
`;
}
}setHead() sets the document title and head tags for the current render. Because a layout runs on every page beneath it, it's the natural place to configure them.
Composing with components
App chrome like a navbar or footer belongs in a component, instantiated in the layout with .create(). Every page beneath the layout then carries it, and its interactive state (the logged-in user, an open menu) lives in one place:
// src/routes/_layout.ts
import { Layout, html } from "@kyln/core";
import { Navbar } from "../components/navbar";
export default class AppLayout extends Layout {
render(content: Node) {
return html`
<div class="app">
${Navbar.create()}
<main>${content}</main>
</div>
`;
}
}This mirrors the scaffolded webapp, whose root layout renders its Navbar component above the page content.
Where components live. Components compose inside layouts and inside other components; a route's render() sticks to its own markup for the first paint. When a route does want a component per item, it renders them behind client-side state, the way the task tracker shows a loading placeholder on first paint and maps tasks to TaskCard.create(...) once mounted. Templates & reactivity covers writing components.
Nested layouts
Layouts compose along the directory tree. A _layout.ts in src/routes/ wraps the whole app; a second one in src/routes/blog/ wraps only /blog pages, nested inside the root layout. You get a layout chain from the outside in, matching the folder nesting.
Special files
Alongside _layout, three more underscore-prefixed conventions shape a directory:
| File | Purpose |
|---|---|
_layout.ts |
Wraps every page in this directory and below. |
_guard.ts |
Gate access to this directory and everything below it (server-enforced). |
_error.ts |
Render when a route in this directory throws. |
_loading.ts |
Render while a route's data is loading. |
Guards, error boundaries, and loading states each get their own guide. This page covers the routing and layout model they build on.
Next
Pages return markup from html and stay in sync with your data through signals. That's the subject of Templates & reactivity.