Routing
Routing in Primate maps incoming HTTP requests to backend logic. Primate uses
filesystem-based routing: it breaks down the request path and maps it
to a file in routes.
Filesystem-based routing
In filesystem-based routing, files map directly to URL paths.
/matchesroutes/index.ts/usermatchesroutes/user.ts/user/profilematchesroutes/user/profile.ts
Route files may contain path parameters denoted with brackets. Resolved
parameters are available to route handlers as request.path.
| Request path | Route file | * matches |
|---|---|---|
/ |
index.ts | |
/user |
user.ts | |
/user/* |
user/[id].ts | anything except / |
/user/* |
user/[...id].ts | anything including / |
/user, /user/* |
user/[[id]].ts | anything except / |
/user, /user/* |
user/[[...id]].ts | anything including / |
[...name]) match across /
and must be the final segment of a route path.Path normalization
Primate cleans up URLs before matching to make them consistent (e.g., collapsing slashes, ignoring trailing ones).
| Request path | Normalized to | Why |
|---|---|---|
/ |
/ |
Root is special |
/user/ |
/user |
Ignores trailing slash |
/user//profile |
/user/profile |
Collapses multiple slashes |
/docs/index |
/docs |
Treats explicit 'index' as parent |
Route resolution
Primate matches the normalized path to files by traversing segments like a folder tree, prioritizing exact matches for predictability.
- Split into segments —
/user/profilebecomes["user", "profile"]. - Match step by step
- Static first — Exact file or directory names (
user.ts). - Then dynamic —
[param].tsor[[param]].tsfor one segment. - Rest if last —
[...param].tsor[[...param]].tsfor the remainder.
- Static first — Exact file or directory names (
- Optional fallback — If no exact match at the end, try an optional param with an empty value.
This ensures static > required > optional. If nothing matches, it's a 404.
Rules for clean setups
Primate checks for ambiguities at startup and errors out if found.
- One dynamic per level — Can't mix single (
[id]) and rest ([...id]) under the same dir. - No overlaps — Avoid
a.ts+a/index.ts, or static + same-level optional (e.g.,user.ts+user/[[id]].ts). - Endpoints only — Optionals and rests can't have subfiles; they're leaves.
| Defined routes | Request | Resolved file | Notes |
|---|---|---|---|
routes/user.ts |
/user |
routes/user.ts |
Static wins |
routes/user/[id].ts |
/user/42 |
routes/user/[id].ts |
Captures one |
routes/user/[...name].ts |
/user/john/adams |
routes/user/[...name].ts |
Captures rest |
routes/[[id]].ts (no index.ts) |
/ |
routes/[[id]].ts |
Empty optional |
routes/docs.ts |
/docs/index |
routes/docs.ts |
Normalization |
routes/a/[id].ts + routes/a/[...rest].ts |
— | Dynamics conflict | |
routes/user.ts + routes/user/[[id]].ts |
— | Static shadows optional | |
routes/a.ts + routes/a/index.ts |
— | Overlap |
Static routes
Static routes have the highest priority in route resolution.
| Request path | Route file |
|---|---|
/ |
index.ts |
/user |
user.ts |
/user/john |
user/john.ts |
index.ts file inside a directory
(e.g., routes/user/index.ts for /user). This is equivalent to user.ts —
pick one style per route. Using directories with index.ts is handy for
grouping routes to add special files like layouts or hooks.Dynamic routes
Dynamic routes use single brackets for a single segment. The parameter matches
any value except /.
routes/user/[name].ts matches:
| Request path | Matches |
|---|---|
/user/2 |
request.path.get("name") is "2" |
/user/john |
request.path.get("name") is "john" |
/user |
parameter required |
/user/john/adams |
doesn't match / |
Rest routes
Rest routes use single brackets with three dots. The parameter matches any
value including /.
routes/user/[...name].ts matches:
| Request path | Matches |
|---|---|
/user/2 |
request.path.get("name") is "2" |
/user/john |
request.path.get("name") is "john" |
/user |
parameter required |
/user/john/adams |
request.path.get("name") is "john/adams" |
/, rest segments must be the final segment of a
route path.Optional routes
Optional routes use double brackets. The parameter matches any value
except / and can be empty (i.e., absent).
routes/user/[[name]].ts matches:
| Request path | Matches |
|---|---|
/user/2 |
request.path.try("name") is "2" |
/user/john |
request.path.try("name") is "john" |
/user |
request.path.try("name") is undefined |
/user/john/adams |
doesn't match / |
Optional parameters may only appear at the end of a route path.
.try("key") (or a schema default).
.get("key") throws when the parameter is absent.Optional rest routes
Optional rest routes use double brackets with three dots. The parameter
matches any value including / and can be empty.
routes/user/[[...name]].ts matches:
| Request path | Matches |
|---|---|
/user/2 |
request.path.try("name") is "2" |
/user/john |
request.path.try("name") is "john" |
/user |
request.path.try("name") is undefined |
/user/john/adams |
request.path.try("name") is "john/adams" |
Route handlers
Route files may contain one or more route handlers that match the request's HTTP verb. Handlers map requests to responses.
import route from "primate/route";
route.get(request => "Hello from GET!");
route.post(request => "Hello from POST!");import route from "primate/route";
route.get(request => "Hello from GET!");
route.post(request => "Hello from POST!");package main
import (
"github.com/primate-run/go/route"
)
var _ = route.Get(func(request route.Request) any {
return "Hello from GET!"
})
var _ = route.Post(func(request route.Request) any {
return "Hello from POST!"
})from primate import Route
@Route.get
def get(request):
return "Hello from GET!"
@Route.post
def post(request):
return "Hello from POST!"require 'primate/route'
Route.get do |request|
"Hello from GET!";
end
Route.post do |request|
"Hello from POST!";
endDisable body parsing
Handlers receive a parsed request body based on Content-Type. To keep the
request body unparsed, pass { parseBody: false } in the route options.
import route from "primate/route";
// request.body is always null in GET requests
route.get(request => "Hello from GET!");
// body parsing turned off, request.body will be null
route.post(request => request.forward("https://my.domain"),
{ parseBody: false });import route from "primate/route";
// request.body is always null in GET requests
route.get(request => "Hello from GET!");
// body parsing turned off, request.body will be null
route.post(request => request.forward("https://my.domain"),
{ parseBody: false });This lets you forward the incoming request as-is to another backend.
Special files
Files whose names start with + are special: they do not map to HTTP
paths. Instead, they influence how routes in their directory (and below) behave.
| Special file | Purpose | Recursive |
|---|---|---|
| +layout.ts | Wrap route output in a layout | |
| +hook.ts | Intercept and transform requests | |
| +error.ts | Handle errors thrown by routes |
Layouts
Layouts live in +layout.ts and compose from the route's directory upward.
The route's content is rendered into the nearest layout (same directory,
if present), which is then rendered into the next parent layout, and so on up
to routes/.
import response from "primate/response";
import route from "primate/route";
route.get(() => response.view("layout.jsx"));The rendered layout component must render the route content via a slot/children, depending on your frontend.
export default function Layout({ children }) {
return <>
<nav></nav>
<main>{children}</main>
</>;
}<nav></nav>
<main><slot /></main>Hooks
Hooks live in +hook.ts. A hook intercepts all routes in its directory and
below. Hooks execute top-down: the highest parent hook runs first; it
calls next(request) to pass control to the next hook; finally the route runs.
// routes/admin/+hook.ts
import hook from "primate/route/hook";
hook((request, next) => {
if (request.query.try("password") === "opensesame") {
return next(request);
}
return "wrong";
});- Return
next(request)to continue to the next hook or route handler. - Return any other response to short-circuit (e.g., redirect, error page).
- You must return something — returning
undefinedis an error.
Request context
Hooks often compute values that downstream hooks and route handlers need (auth user, locale, feature flags, request IDs, etc.). Use request context to attach those derived values to the request as it flows through the hook chain.
Set context in a hook with request.set():
// routes/+hook.ts
import hook from "primate/route/hook";
hook((request, next) => {
request.set("user", { id: 1, name: "John" });
return next(request);
});Read it in nested hooks or route handlers with request.get() or request.try():
// routes/dashboard/index.ts
import route from "primate/route";
route.get(request => {
const user = request.get<{ id: number; name: string }>("user");
return { greeting: `Hello, ${user.name}` };
});RequestFacade context is mutable. request.set(...) updates the current
request's context for downstream hooks and the route handler.For the full API and semantics, see Handling requests → Request context.
next(request).Error files
Error files live in +error.ts. An error handler applies to its directory
and all subdirectories, but handlers do not compose: when an error is
thrown, the nearest +error.ts up the directory tree handles it. Only one
error handler runs.
import response from "primate/response";
import route from "primate/route";
route.get(() => response.redirect("/"));+error.ts (same directory, then parent, etc.)
takes precedence. Unlike layouts and hooks, error handlers aren't layered.