Targ Apps Docs
Chroma

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 routerAPI router
Question it answersWhich screen is showing for this URL?What does fetch("/api/...") return?
Where it livesEngine export createRouter from pkg/chroma.js (WASM)createRouter in the @chroma/router plugin (browser JS)
Route sourceA route table you pass in code, usually one entry per file in src/screens/Files under api/<path>/<METHOD>.js
ReferenceAPI referenceThis 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).

TopicContract
Handler locationapi/<segments>/<METHOD>.js next to chroma.json
URL prefixAlways /api/...
MethodsGET, POST, PUT, DELETE, CUSTOM only
Dynamic segmentsFolder [id] becomes URL param :id
Dev serverDoes not run handlers. A request that is not intercepted in the browser reaches it, and POST/PUT on /api/* return 405
ProductionSame as dev: dist/api/ plus the manifest, run in the browser after bootstrapRouter()

Quick start

  1. Add "chroma-router" to plugins in chroma.json (templates from chroma new already include it).
  2. Create a handler, e.g. api/hello/GET.js.
  3. 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.
  4. Call loadPlugins() before render() (template default), which runs bootstrapRouter().
  5. From app code in .chr files, call fetch("/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 dev

File convention

Place handlers under api/ at the project root (next to chroma.json). Each file name is the HTTP method; folders become path segments.

FileRoute
api/hello/GET.jsGET /api/hello
api/accounts/GET.jsGET /api/accounts
api/accounts/[id]/GET.jsGET /api/accounts/:id
api/transactions/POST.jsPOST /api/transactions
api/sync/CUSTOM.jsCUSTOM /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

MethodHandler signatureNotes
GET(req) => responsereq.query from ?key=value
POST(req) => responsereq.body parsed from JSON when possible
PUT(req) => responseSame body rules as POST
DELETE(req) => response
CUSTOM(req, operation) => responseoperation from X-Custom-Operation header

Request object (req)

FieldTypeMeaning
methodstringUppercase verb
urlstringFull URL passed to fetch
pathstringPathname only
paramsRecord<string, string>:id captures from the route
queryRecord<string, string>Search params
bodyunknownParsed JSON body on POST/PUT when possible
customOperationstring | nullFrom 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.

FieldDefaultMeaning
status200HTTP 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:

OptionDefaultMeaning
manifestUrl"./api/routes-manifest.json"Where to load the manifest
manifest—Inline manifest object (skip fetch)
hijackFetchtruePatch 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

ExportDescription
CHROMA_METHODS["GET","POST","PUT","DELETE","CUSTOM"]
ChromaRouterRouter 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 path
  • registerRoutes([{ path, handlers }])
  • loadFromManifest([{ path, module }]) — dynamic import()
  • dispatch(method, url, body?, customOperation?) — run a handler without fetch
  • hijackFetch() — wrap window.fetch (no-op when routes.length === 0)

Fetch hijack behavior

When hijackFetch() is active and at least one registered route lives under /api:

  1. Resolve the request pathname (relative URLs use window.location.origin).
  2. If there are API routes but the pathname does not start with /api, use native fetch (CDN, WASM, assets).
  3. If the pathname does not match any registered pattern, use native fetch.
  4. On match, call dispatch for the HTTP method and return a synthetic Response with JSON.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

EnvironmentWho serves /api/*?
chroma devBrowser (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.

On this page