Quickstart
Two sites: the smallest possible one, then one with a database. Read How a site works first if you have not.
1. The smallest site
site/
public/
index.html
api/
hello.js
public/index.html:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Hello</title></head>
<body>
<h1>Hello</h1>
<p id="out">Loading...</p>
<script type="module">
const response = await fetch("/api/hello");
document.querySelector("#out").textContent = (await response.json()).message;
</script>
</body>
</html>
api/hello.js (served at /api/hello by convention, no manifest needed):
export default async function handle(request, ctx) {
return Response.json({ message: "Hello from MicroHost", method: request.method });
}
The default export is an async function that resolves to a Response. Returning anything else (a string, an object, undefined) fails with HTTP 502. An uncaught exception fails with HTTP 500.
Package it so the ZIP root contains public/ and api/ directly (not a wrapping folder):
cd site && zip -r ../site.zip public api
Upload site.zip (see Deploying) and activate the release. curl https://your-site.example/api/hello answers with JSON, and / serves the page. This site needs no manifest and no grants: with no manifest, all dynamic capabilities (database, storage, secrets, mail, network) are off.
2. A site with a database
Add a migration, a manifest, and a handler.
site/
microhost.json
private/
migrations/
0001_notes.sql
api/
notes.js
public/
index.html
private/migrations/0001_notes.sql:
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
created_at TEXT NOT NULL
);
microhost.json: request the db SDK module and the database permission.
{
"schemaVersion": 1,
"sdk": ["db"],
"permissionsRequested": { "database": true }
}
api/notes.js:
import { notes, now } from "microhost/db";
export default async function handle(request, ctx) {
if (!ctx.db) return Response.json({ error: "database_unavailable" }, { status: 503 });
if (request.method === "GET") {
const rows = await notes.where().orderBy("id", "desc").limit(20).all();
return Response.json({ notes: [...rows], hasMore: rows.hasMore });
}
if (request.method === "POST") {
let input;
try {
input = await request.json();
} catch {
return Response.json({ error: "invalid_json" }, { status: 400 });
}
const body = typeof input?.body === "string" ? input.body.trim() : "";
if (!body || body.length > 2000) {
return Response.json({ error: "invalid_body" }, { status: 400 });
}
const note = await notes.insert({ body, created_at: now() });
return Response.json(note, { status: 201 });
}
return new Response("Method not allowed", { status: 405, headers: { allow: "GET, POST" } });
}
What happens:
- At deploy time MicroHost applies
0001_notes.sqlto the site's private SQLite database and seals a schema snapshot with the release. The snapshot is whyimport { notes } from "microhost/db"works: table objects are generated from migrations. A table that no migration creates is not importable. See Migrations. ctx.dbexists only after an administrator grants the site the database capability. The request inmicrohost.jsonis not a grant. Without the grant,ctx.dbisundefinedand any SDK database call throwscapability_denied. Checkingctx.dbfirst lets you answer with a friendly 503.notes.insertrunsINSERT ... RETURNING *and gives you the new row as an object. Values are always bound parameters. See Database SDK.GET /api/notesandPOST /api/notesboth reach the same file: convention routing does not filter methods, so the handler checksrequest.methoditself.
If you prefer raw SQL, ctx.db.query("SELECT ...", [params]) returns { columns, rows, rowsAffected, lastInsertRowid } with array rows. See Context.
Where to go next
- Handler API for everything
RequestandResponseoffer. - Routing for patterned routes such as
/posts/:slug. - Guides for login with sessions and pagination.
- Testing sites: there is no local dev server that runs handlers; read this before planning your workflow.