Menu

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:

If you prefer raw SQL, ctx.db.query("SELECT ...", [params]) returns { columns, rows, rowsAffected, lastInsertRowid } with array rows. See Context.

Where to go next