Distilled from https://markojs.com/docs/marko-run. File-based routing on Vite, streaming SSR, zero config. references/routing.md (files, paths, flat routes, execution order), references/handlers.md (verb helpers, validation, data loading, context, typed URLs, embedding), references/deploy.md (vite plugin, adapters, CLI).
Mental model
src/routes/**— only+-prefixed files are routable; everything else is ignored (colocate components/tests freely).- Request → middlewares (root→leaf) → handler → layouts (root→leaf) → page. Layouts+page compile into one component.
ctx(handlers/middleware) ===$global(templates).ctx.params,ctx.urlare serialized to the browser by default; add others viactx.serializedGlobals.- Data flows down through
next({ ... })→ctx.data/$global.data(shallow merge). Pass promises,<await>them in the page — awaiting beforenextkills streaming. Runis a global (no import):Run.GET/HEAD/POST/PUT/DELETE/PATCH/OPTIONS/QUERY/ALL,Run.href, typesRun.Context,Run.Platform.
Quick reference
Files (any depth under routes)
| File | Role |
|---|---|
+page.marko |
GET → HTML. One per path. |
+layout.marko |
wraps nested layouts/pages; input.content = child. $global = ctx. |
+handler.ts |
export const GET/POST/... = Run.X(...). One per path. Return Response, or undefined → next() auto. Missing HEAD → GET minus body. |
+middleware.ts |
export default Run.ALL(...) (all verbs) or Run.POST(...) (one verb); runs before handlers; options-only form Run.ALL({ search }) attaches validation below. |
+meta.json / +meta.ts |
static ctx.meta; verb keys ("POST": {...}) shallow-override per method. |
+404.marko, +500.marko |
root-only; when Accept has text/html; wrapped by root layout. |
Path segments
users static · _admin pathless (ignored in URL, scopes layouts/middleware) · $id param ($ alone = match, no capture) · $$rest catch-all to end ($$ no capture; nothing nests inside).
Flat routes: projects.$projectId.members+page.marko; dirs may use it too (projects.$projectId/members+page.marko); merged with nested dirs. Multi-path members,people+page.marko; groups (members,people); optional (home,) or (home,_x). Escape control chars with backticks: `sitemap.xml`/+handler.ts.
Handlers & validation
export const POST = Run.POST({ json: NoteSchema }, async (ctx, next) => {
const [note, issues] = await ctx.body; // Standard Schema → [value, issues]; fn validator → its return
if (issues) return Response.json({ issues }, { status: 422 });
return ctx.redirect("/thanks"); // PRG on success; next({ issues }) to re-render form with errors
});
- Options:
params,search(fn or sync Standard Schema; lazy, cached on first access),json(validator,maxBytes1MiB),form(validator,maxBytes,maxParts1000,maxFiles20,maxFileBytes1MiB,onFile(ctx, file); repeated fields → arrays). Forms:Run.POST(options, [auth, handler])arrays compose. ctx.bodyis a promise (await ctx.body), lazy,undefinedwhen nojson/formoption. Don't callctx.request.json()— bypasses validation/limits.- Options merge across middleware+handler (last validator wins, objects shallow-merge).
ctxmethods:fetch(url, init)(internal re-request, setsparent),render(template, input, init),redirect(to, status=302),back(fallback="/", status).Run.href("/projects/$projectId/members", { params: { projectId: 42 }, search: {...}, hash })— typed against real routes when.marko-run/routes.d.tsis in tsconfiginclude; rewritten at compile time on the client.
Types
Generated .marko-run/routes.d.ts (needs tsconfig at root; add to include) narrows Run.Context per file (params, meta, search, body, upstream data). Extend globally: declare module "@marko/run" { interface Context { db: DB } interface Platform { ... } } — reserve for app-wide props; per-route data goes through next().
Config / deploy
- No
vite.configneeded. When needed:import marko from "@marko/run/vite"; plugins: [marko({ adapter, routesDir, trailingSlashes })](+ all@marko/viteoptions).trailingSlashes:RedirectWithout(default) |RedirectWith|RewriteWithout|RewriteWith|Ignore. - Adapter resolution: option → first dep named
@marko/run-adapter*/*marko-run-adapter*→ built-in Node. Packages:@marko/run-adapter-node(Express middleware:routerMiddleware(),matchMiddleware()→req.route.invoke),@marko/run-adapter-static(urls: [...] | async fn, crawls links),@marko/run-adapter-netlify({ edge: true }+netlify.tomlfunctions/edge_functions = "dist", publishdist/public). - Embed:
import * as router from "@marko/run/router"→router.fetch(request, platform),router.match(method, path)+router.invoke(route, request, platform). - CLI (
marko-run, run viabun run/bunx):dev(default,-p),build(-o),preview(-o -p -f); all take-c config,-e dotenv.
Common mistakes
- Awaiting data in
GETbeforenext()→ whole response blocked. Pass the promise. - Returning
next()from a successfulPOST→ refresh resubmits, URL unshareable. Redirect instead. - Async Standard Schema validators throw at runtime — sync only.
ctx.body()— it's a promise, not a function.- Handler that always returns its own
Responsenever renders the page (its verb is dropped from the page's typed context). - Nesting route files inside a
$$catch-alldir — never traversed. - Expecting
$global.sessionon the client — onlyparams/urlserialize by default; add toctx.serializedGlobals(and never secrets).