Routing
Choose between the screen router and the API router, and set up browser-side /api handlers with fetch interception.
Chroma has two routers with similar names. Pick the one that matches your goal:
| Screen router | API router | |
|---|---|---|
| Question it answers | Which screen is showing for this URL? | What does fetch("/api/...") return? |
| Where it lives | Engine export createRouter from pkg/chroma.js (WASM) | createRouter in the @chroma/router plugin (browser JS) |
| Route source | A route table you pass in code, usually one entry per file in src/screens/ | Files under api/<path>/<METHOD>.js |
| Reference | API reference | This page |
This page covers the screen router briefly, then the API router in detail.
Screen router
Put each screen in src/screens/ and register them in one route table. The router renders the matching screen and updates when the URL changes (hash mode by default).
// src/router.chr
import { e, createRouter } from "../pkg/chroma.js";
import { Home } from "./screens/Home.chr";
import { Item } from "./screens/Item.chr";
export const router = createRouter(
[
{ path: "/", component: () => e(Home, {}) },
{ path: "/items/:id", component: (props) => e(Item, { id: props.params.id }) },
],
{ mode: "hash" },
);// src/main.chr
import { render, e } from "../pkg/chroma.js";
import { router } from "./router.chr";
render(() => e(router.View, {}), document.getElementById("app"));
// router.navigate("/items/42");Use mode: "history" only when your host rewrites unknown paths to index.html; the Chroma dev server does not. Signatures for createRouter, matchRoute, and parseQuery are in the API reference.
API router
Handlers live in plain .js files under an api/ folder. At runtime the @chroma/router plugin matches the URL and runs the handler inside the browser, so fetch("/api/hello") works without Node.js or a backend. Use it for simulated endpoints and local handlers: demo CRUD backed by localStorage or Chroma-Fs, or code structured like a small REST API. Chroma Server only serves static files (GET/HEAD).
| Topic | Contract |
|---|---|
| Handler location | api/<segments>/<METHOD>.js next to chroma.json |
| URL prefix | Always /api/... |
| Methods | GET, POST, PUT, DELETE, CUSTOM only |
| Dynamic segments | Folder [id] becomes URL param :id |
| Dev server | Does not run handlers. A request that is not intercepted in the browser reaches it, and POST/PUT on /api/* return 405 |
| Production | Same as dev: dist/api/ plus the manifest, run in the browser after bootstrapRouter() |
Quick start
- Add
"chroma-router"topluginsinchroma.json(templates fromchroma newalready include it). - Create a handler, e.g.
api/hello/GET.js. - List the handler in
api/routes-manifest.json(see Manifest). The CLI does not generate this file; edit it by hand or register routes in code. - Call
loadPlugins()beforerender()(template default), which runsbootstrapRouter(). - From app code in
.chrfiles, callfetch("/api/hello").
// api/hello/GET.js
export async function GET(req) {
return {
status: 200,
data: { message: "Hello", query: req.query },
};
}// src/screens/Home.chr
const res = await fetch("/api/hello?name=Ada");
const json = await res.json();
// json.data.message === "Hello"chroma devFile convention
Place handlers under api/ at the project root (next to chroma.json). Each file name is the HTTP method; folders become path segments.
| File | Route |
|---|---|
api/hello/GET.js | GET /api/hello |
api/accounts/GET.js | GET /api/accounts |
api/accounts/[id]/GET.js | GET /api/accounts/:id |
api/transactions/POST.js | POST /api/transactions |
api/sync/CUSTOM.js | CUSTOM /api/sync |
Each file exports a named function matching the method (not a default export):
// api/accounts/[id]/GET.js
export async function GET(req) {
const id = req.params.id;
return {
status: 200,
data: { id, label: `Account ${id}`, balance: 0 },
};
}// api/transactions/POST.js
export async function POST(req) {
const payload = req.body;
return { status: 201, data: { id: String(Date.now()), ...payload } };
}Bracket folders work like Next.js dynamic segments: api/users/[id]/GET.js serves GET /api/users/42 with req.params.id === "42".
Reserved methods
| Method | Handler signature | Notes |
|---|---|---|
GET | (req) => response | req.query from ?key=value |
POST | (req) => response | req.body parsed from JSON when possible |
PUT | (req) => response | Same body rules as POST |
DELETE | (req) => response | |
CUSTOM | (req, operation) => response | operation from X-Custom-Operation header |
Request object (req)
| Field | Type | Meaning |
|---|---|---|
method | string | Uppercase verb |
url | string | Full URL passed to fetch |
path | string | Pathname only |
params | Record<string, string> | :id captures from the route |
query | Record<string, string> | Search params |
body | unknown | Parsed JSON body on POST/PUT when possible |
customOperation | string | null | From header for CUSTOM |
Handler response
Handlers return a plain object (serialized as JSON). The hijacked fetch wraps it in a Response with Content-Type: application/json.
| Field | Default | Meaning |
|---|---|---|
status | 200 | HTTP status on the synthetic Response |
data, error, … | — | Any JSON-serializable fields you define |
On thrown errors, the router returns { status: 500, error: "<message>" }.
End-to-end flow
When bootstrapRouter() runs with hijackFetch: true (default), your app’s fetch is wrapped once. Only paths that match a registered /api route are handled locally; everything else uses the real browser fetch (WASM, CSS, manifest, or a remote URL).
If you fetch("/api/unknown") and no route matches, the call falls through to native fetch. Chroma Server answers 405 Method Not Allowed for POST/PUT on /api/* because it only supports GET/HEAD. Register the paths you call, or use absolute URLs to a real backend.
Manifest
api/routes-manifest.json maps URL paths to handler modules. You maintain it by hand, and chroma build copies the whole api/ tree into outDir (default dist/). Bracket segments [id] become :id URL parameters.
Example manifest (multiple method files, same path — merged at load time):
{
"routes": [
{ "path": "/api/status", "module": "./api/status/GET.js" },
{ "path": "/api/status", "module": "./api/status/POST.js" }
]
}Registering routes in a project
1. Declare the plugin
{
"plugins": ["chroma-router"]
}chroma new vendors plugins from ~/.chroma/plugins/ into plugins/.
2. Bootstrap at startup
The template's plugins/loader.js calls the plugin's bootstrapRouter() hook for you (see Plugins). To call it yourself:
// src/main.chr
import { bootstrapRouter } from "../plugins/chroma-router/index.js";
const router = await bootstrapRouter();bootstrapRouter() loads ./api/routes-manifest.json, registers handlers (merging exports when several manifest entries share one path), and optionally hijacks window.fetch.
Options:
| Option | Default | Meaning |
|---|---|---|
manifestUrl | "./api/routes-manifest.json" | Where to load the manifest |
manifest | — | Inline manifest object (skip fetch) |
hijackFetch | true | Patch window.fetch when routes exist |
3. Keep the manifest in sync
After adding or renaming a route file, update api/routes-manifest.json to match.
4. Manual registration (alternative to the manifest)
// src/api-setup.chr
import { createRouter } from "../plugins/chroma-router/index.js";
import * as hello from "../api/hello/GET.js";
const router = createRouter();
router.registerRoute("/api/hello", hello);
router.hijackFetch();Exports
| Export | Description |
|---|---|
CHROMA_METHODS | ["GET","POST","PUT","DELETE","CUSTOM"] |
ChromaRouter | Router class |
createRouter() | Factory |
bootstrapRouter(options?) | Load manifest + optional fetch hijack |
filePathToRoute(relPath) | users/[id]/GET.js → /api/users/:id |
parseRoutesManifest(manifest) | Normalize manifest entries |
ChromaRouter methods
registerRoute(path, handlers)— merge handlers for the same pathregisterRoutes([{ path, handlers }])loadFromManifest([{ path, module }])— dynamicimport()dispatch(method, url, body?, customOperation?)— run a handler withoutfetchhijackFetch()— wrapwindow.fetch(no-op whenroutes.length === 0)
Fetch hijack behavior
When hijackFetch() is active and at least one registered route lives under /api:
- Resolve the request pathname (relative URLs use
window.location.origin). - If there are API routes but the pathname does not start with
/api, use nativefetch(CDN, WASM, assets). - If the pathname does not match any registered pattern, use native
fetch. - On match, call
dispatchfor the HTTP method and return a syntheticResponsewithJSON.stringify(result).
:id segments in registered paths become capture groups; registerRoute merges handler exports when you register the same path twice (as loadFromManifest does for multiple method files).
Set globalThis.__CHROMA_DEBUG__ = true before bootstrapRouter() to log [ChromaRouter] fetch hijack active for local API routes. when the patch is installed.
CUSTOM example
// api/sync/CUSTOM.js
export async function CUSTOM(req, operation) {
if (operation === "rebuild-index") {
return { status: 200, data: { ok: true } };
}
return { status: 400, error: "Unknown operation" };
}await fetch("/api/sync", {
method: "CUSTOM",
headers: { "X-Custom-Operation": "rebuild-index" },
});Chroma Desktop, dev server, and static hosting
| Environment | Who serves /api/*? |
|---|---|
chroma dev | Browser (bootstrapRouter), not engine |
| Chroma Desktop (imported zip) | Browser in the app iframe |
Static host (dist/ on Netlify, S3, etc.) | Browser only if the built app includes api/ + manifest and calls bootstrapRouter() |
Chroma Server serves static files only — see static hosting and chroma build output for production dist/ deploy. API routes are not server-side functions; they are part of your client bundle workflow. For persistence, pair routes with Chroma-Fs (IndexedDB in the browser, filesystem bridge in Desktop) rather than expecting engine to write files.
Example project
See Chroma/example/ — api/webhook/ handlers (simulated endpoints with GET, POST, DELETE, and CUSTOM) and a demo in index.html. It pairs UI in .chr files, storage via Chroma-Fs, and an /api layer for structured fetch calls.
License
@chroma/router is proprietary software. See Licensing.