Documentation
Routing
File-based, Next.js-style conventions resolved by Bun's own router.
Routes map from the filesystem using the conventions most developers already know. Path resolution is delegated to Bun.FileSystemRouter rather than reimplemented.
routes/index.tsx -> /
routes/about.tsx -> /about
routes/blog/[slug].tsx -> /blog/:slug
routes/api/subscribe.ts -> /api/subscribePages and actions
A module that default-exports a component is a page. A module that exports HTTP method handlers is a server action. Nothing else distinguishes them — there is no config file listing routes.
import type { PageProps } from "stoneware";
import { getPost } from "../../lib/posts.ts";
export default function Post({ params }: PageProps) {
const post = getPost(params.slug);
if (!post) return <h1>Not found</h1>;
return (
<article>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
</article>
);
}Params arrive percent-decoded and are escaped like any other value when interpolated, so a slug containing markup is inert.
Catch-all segments
A bracketed spread swallows the rest of the path. Two forms, and the difference is whether the route also answers the bare parent path.
routes/docs/[...path].tsx -> /docs/a and /docs/a/b/c
but NOT /docs
routes/files/[[...path]].tsx -> /files and /files/a/b/cThe captured value is a single string with the segments joined by a slash — params.path is "a/b/c", not an array. Split it yourself if you need the parts. For the optional form matching zero segments, the param is absent rather than an empty string, so params.path is undefined and a check for it means what it looks like it means.
import { notFound, type PageProps } from "stoneware";
export default function Doc({ params }: PageProps) {
const segments = params.path.split("/");
const page = lookup(segments);
if (!page) notFound();
return <article>{page.body}</article>;
}A catch-all is only ever the last segment. Anything after it could never match, so the pattern would be a route that cannot be reached.
Which route wins
When more than one pattern could match a path, the most specific one answers. Specificity is decided segment by segment, at the first position where two patterns differ, in this order:
1 literal /blog/about 2 param /blog/[slug] 3 catch-all /blog/[...rest] 4 optional /blog/[[...rest]]
So /blog/about reaches routes/blog/about.tsx even though [slug] and [...rest] would both have matched it. This is the precedence Next.js documents, which is the one most people arrive expecting. The table is compiled once at startup and ordered, so matching returns the first hit rather than scoring candidates on every request.
You never have to work it out from filenames. stoneware routes prints the compiled table in the order patterns are actually tried, alongside whether each is a page or an action.
Paths that never reach a route
Some request paths are refused during matching and answer 404 without any route being consulted. These are not conditions your code has to defend against, and they are worth knowing because each one is a bypass in frameworks that get it wrong.
- An encoded slash stays inside its segment. The path is split before it is decoded, so /blog/a%2Fb is one segment containing a slash and can never satisfy a two-segment route — the path-confusion bypass that decoding first would open.
- An empty segment is refused. //a and /a//b are 404 rather than being read as /a and /a/b, so two spellings can never resolve to one route.
- A malformed escape is refused. /blog/%zz or a bare % is 404 rather than an exception.
- A NUL byte anywhere in the path is refused.
- Leading and trailing slashes are trimmed, so /about/ and /about are the same route.
Methods
A page answers GET and HEAD. Anything else gets 405 with an Allow: GET, HEAD header — a page is a document, and POSTing to one is a mistake worth naming rather than a 404 that reads as a typo.
An action answers exactly the methods it exports. A request for one it does not gets 405 with Allow listing the ones it does. HEAD falls through to the GET handler when there is one, and the body is dropped on the way out — so a HEAD never runs different code from the GET it is asking about.
A mutating request with no CSRF token is 403 before any of this, because verification runs ahead of matching. So a bare curl -X POST at a page answers 403, not 405 — the request never got far enough to be told which methods the route allows.
A leading underscore marks a file as a convention rather than a page. routes/_404.tsx, routes/_500.tsx and routes/_middleware.ts are never servable at their own paths — without that, the error page would answer a real request at /_404 with a 200.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.