File-based routing
Routes live in src/routes/. Folders are URL segments. A file's first character sets its role. There is no route config; the tree is the routing table.
routes/
_middleware.ts every route
_index.tsx /
kontakt.tsx /kontakt
admin/
_middleware.ts /admin/* (runs after the root middleware)
_index.tsx /admin
dashboard.tsx /admin/dashboard
$id.tsx /admin/:id
detail/
$.tsx /detail/* (catch-all)
File roles
| Prefix | Role |
|---|---|
_index |
the folder's own route |
_middleware |
middleware for this folder and below |
_404 |
not-found renderer for this folder |
_error |
error boundary for this folder |
$name |
dynamic segment → :name |
$ |
catch-all → :splat(*) |
a–z… |
a URL segment |
A–Z… |
a co-located component, not a route |
Static routes register before dynamic ones, and longer paths before shorter, so the most specific match wins.
Page routes and resource routes
A route is a page route if it has a default export. It renders HTML. It can also export get, post, head, and error.
export default function Home() {
return <h1>Hello</h1>;
}
A route with no default is a resource route. Handlers are HTTP-verb exports: get handles GET, post handles POST, and put, patch, del handle the rest (del, because delete is a reserved word). Use these for Datastar endpoints, JSON APIs, and raw responses.
import type { PostFunction } from 'camelon';
export type PostInput = { signals: { count: number } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
stream.patchSignals({ count: signals.count + 1 });
};
A request runs the export matching its HTTP method. The route renders its default export if it has one, otherwise the verb owns the response. HEAD is served by get with the body dropped; a request for a verb the route doesn't export gets a 405 with an Allow header listing the ones it does. put, patch, and del never render a default — they aren't navigations, so they're resource-only. Mutating verbs (post/put/patch/del) are one-shot: they run, commit any session cookie, and return. A long-lived SSE stream must be a get — the response sets cookies before the stream body opens, which only the GET lane allows.
export default function Counter() {
return (
<div class="d-row" signals='{"count": 0}'>
<button class="d-btn" on:click="@post('/counter/decrement')">−</button>
<output class="d-num" text="$count">0</output>
<button class="d-btn" on:click="@post('/counter/increment')">+</button>
</div>
);
}import type { PostFunction } from 'camelon';
export type PostInput = { signals: { count: number } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
stream.patchSignals({ count: signals.count + 1 });
};import type { PostFunction } from 'camelon';
export type PostInput = { signals: { count: number } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
stream.patchSignals({ count: signals.count - 1 });
};params
A $name file captures one segment, $ captures the rest. Read them from params.
import type { GetFunction } from 'camelon';
export type GetInput = { params: { id: string } };
export type GetOutput = { id: string };
export const get: GetFunction<GetInput> = ({ params }) => {
return { id: params.id };
};
export default function User({ id }: GetOutput) {
return <h1 safe>User {id}</h1>;
}export default function Users() {
return (
<ul>
<li>
<a href="/users/ada">Ada</a>
</li>
<li>
<a href="/users/lin">Lin</a>
</li>
</ul>
);
}And $ catches the rest of the path — edit the URL in the demo's address bar:
import type { GetFunction } from 'camelon';
export type GetInput = { params: { splat: string } };
export type GetOutput = { rest: string };
export const get: GetFunction<GetInput> = ({ params }) => ({ rest: params.splat });
export default function Splat({ rest }: GetOutput) {
return (
<div class="d-card">
<strong>catch-all route</strong>
<p>
captured: <code>/{rest}</code>
</p>
</div>
);
}Next: middleware and errors.