Routes
The file convention and defineHandler.
An API route is one file. Its path under api/ is the URL, a suffix on the name picks the HTTP method, and the default export handles the request:
import { defineHandler, notFound, query } from 'ohnejs';
export default defineHandler(async ({ params }) => {
const author = await query('Authors').where('UUID', params.id).findFirst();
if (!author) throw notFound();
return author;
});GET /authors/42 runs the handler with params.id set to '42' and returns the author as JSON. There is no router to configure and no table to register - the files are the routing. ohne dev picks up a new file as you save it.
The handler receives { params }, may be sync or async, and its result type is inferred from what you return. Inside it:
- Composables read the rest of the request: the body, search params, cookies, headers, and the negotiated language.
- Response composables set the status and headers, redirect, and stream.
- Failures are thrown:
notFound(),badRequest(), and the other named constructors make anHTTPErrorthat maps to its status.
Files and URLs
Routes live in each layer's dirs.api directory, api/ by default, set in config. The relative path becomes the URL:
- Directories are segments.
- The extension is stripped.
- A trailing
indexis dropped, so the file serves its directory's URL.
api/index.get.ts GET /
api/authors.get.ts GET /authors
api/authors.post.ts POST /authors
api/authors/[id].get.ts GET /authors/[id]
api/files/[...path].get.ts GET /files/[...path]The method suffix sits before the extension: .get, .post, .put, .patch, .delete, .head, or .options, case-insensitive. A file without one answers every method on its path.
api.basePath puts the whole route table under one prefix:
'/api'serves every route at/api/....- A request outside the prefix is a
404. - Handlers still see the path without the prefix.
These rules prevent confusion in the table:
api/authors.get.tsandapi/authors/index.get.tsname the same route, so defining both in one layer is an error. One would hide the other without a warning.- Every
.tsfile in the directory is a route. There is no_convention for helper files here, because an underscore can start a real URL segment. So shared code lives outside the directory.
Route params
A [name] segment, in a file or a directory name, matches exactly one URL segment, and its value is set on params under that name. A [...name] catch-all matches one or more segments, slashes included:
import { defineHandler } from 'ohnejs';
export default defineHandler(({ params }) => params.path);GET /files/img/logo.svg sets params.path to 'img/logo.svg'. A catch-all needs at least one segment, so /files alone does not match.
Params are always strings, URI-decoded before they reach the handler. Directories can be params too: the framework's own messages endpoint lives at messages/[group]/[language].get.ts and serves GET /messages/[group]/[language].
What a return becomes
The return value decides the response:
- An object, an array, or any other plain value is serialized as JSON.
- A string is sent as
text/html. nullorundefinedsends an empty body with204, unless you set another status.- A
ReadableStream,Uint8Array,ArrayBuffer, orBlobstreams asapplication/octet-stream. - A web
Responseis sent as it is: its status, body, and headers win, and headers you set during the request are merged in with lower priority. - An
HTTPError, returned or thrown, becomes its status and the error shape.
A content-type you set yourself is never overridden, so setting the header first sends a string as text/plain instead of text/html.
Matching
Requests match the most specific pattern first, segment by segment: a static segment beats a [param], which beats a [...catch-all]. /authors/new wins over /authors/[id] no matter how the files sort. A single trailing slash is allowed, so /authors/42/ matches /authors/[id].
Escaped letters, digits and -._~ are decoded before matching, so /%70osts is /posts. A path with an encoded slash (%2F) gets a 404, so a param never holds a / that was not a segment boundary. To pass a value that contains /, use a [...catch-all] or a query param.
Methods you did not define yourself are filled in:
- On the same path, a method-suffixed file wins its method, and a suffixless file answers the rest.
HEADwith noHEADroute is served by the path'sGETroute, and the body is not sent.OPTIONSwith noOPTIONSroute answers204with anAllowheader listing what the path serves. Middleware runs first, so a CORS middleware can answer the preflight.
A path no route matches is a 404. A path that matches, but not for the method, is a 405 with the Allow header.
Per-route options
defineHandler takes options as a second argument:
import { defineHandler, query } from 'ohnejs';
export default defineHandler(() => query('Reports').findMany(), {
handlerTimeout: '5m',
rateLimit: { limit: 10, window: '1m' },
middleware: ['audit-log'],
});maxBodySize,handlerTimeout, andwaitUntilTimeoutoverride their server-wideapi.*config for this one route. Each takes the same values as its config option: a number, a string like'100mb'or'30s', orfalseto remove the limit.rateLimitcaps how often one client may call the route. Rate limiting covers it.middlewareturns on named middleware for the route. They run after the global ones, which always run.
Routes across layers
Every layer adds routes from its own dirs.api, and the tables merge by route id. A route id is the method plus the pattern, like GET /authors/[id]. When two layers define the same id, the closer layer wins, so your app's file overrides the one a layer ships.
To drop a layer's route instead of replacing it, disable it by glob. A glob without a method prefix drops the pattern for every method:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
disable: {
routes: ['/internal/**', 'GET /admin/**'],
},
});