Hubps build guide: overview
Hubps hosts static web apps with a ready-made database API and a ready-made login. You write plain HTML/CSS/JS files, Hubps serves them at https://<slug>.hubps.app.
What you get:
- Hosting with HTTPS, versions and rollback.
- A database: named collections declared in
hubps.json, used through/_hubps/sdk.js. No SQL, no server code. - Login for the owner's colleagues: only listed e-mail addresses get in (magic link by e-mail). The app sees who is logged in via
hubps.user().
What you cannot do (tell the user early, do not fake it):
- No server-side code, no cron jobs, no incoming webhooks, no outgoing e-mail from the app, no file uploads by members (yet), no custom domains (yet).
- No calls to other websites from the browser unless the host is listed in
connectin hubps.json. - No database access from outside the app.
If an idea needs server code, say so, and offer the closest static alternative (for example a form that writes to a collection, or a page that the owner exports).
Workflow
- Call
whoamionce to see the plan, limits and the app domain. - Read this guide (you are doing that).
create_appwith a short name (and optional slug). Apps are private by default.- Write the files, including
hubps.json. Calldeploywith ALL files of the app (mode "replace"), or with only the changed files and mode "merge". set_accesswith the e-mail addresses of the people who may open the app (owner's e-mail is NOT added automatically).- Tell the user the URL. Colleagues get a login link by e-mail when they first open it.
- To change something:
get_files, edit,deployagain. To debug:get_logsshows browser errors and data API errors. To undo:rollback. - Never put secrets, API keys or personal data of third parties into the files. Files of a private app are only served to listed members, but treat them as shared.
Limits
- Files per deploy: 200; total 10 MB; per file 5 MB.
- Allowed file types: html, css, js, mjs, json, svg, png, jpg, jpeg, webp, gif, ico, woff, woff2, ttf, txt, md, csv, xml, webmanifest, map. Binary files (png, jpg, webp, gif, ico, woff, woff2, ttf) must be sent with encoding "base64"; text files with "utf8".
- Paths: relative, "/" as separator, no "..", nothing under "_hubps/" (reserved).
- Records: at most 100 KB each. Lists return at most 200 records per page (use the cursor).
- Writes: 30 per second per app.
- Filtering/sorting on a field that is not listed in "index" works only while a collection has fewer than 10,000 records. Index every field you filter or sort by.
- Up to 20 collections per app, 40 fields per collection, 5 indexed fields per collection.
- Browser security policy (cannot be changed except via "connect"): scripts and styles only from the app itself and these CDNs: cdn.jsdelivr.net, unpkg.com, cdnjs.cloudflare.com, esm.sh. Images only from the app itself, data: and blob: URLs. Prefer to include libraries as files in the app instead of loading them from a CDN.
hubps.json (required, in the root of the app)
{
"name": "Vacation planner",
"entry": "index.html",
"spa": false,
"admins": ["boss@company.com"],
"connect": [],
"collections": {
"requests": {
"fields": { "from": "date", "to": "date", "status": "string", "note": "string?", "days": { "type": "number", "default": 1 } },
"index": ["status", "from"],
"read": "members",
"write": "own",
"admins": []
}
}
}
- name: shown on the login page. entry: the HTML file served at "/" (default index.html). spa: true serves the entry for unknown paths without a file extension.
- fields: types
string,number,boolean,date(ISO "2026-12-24" or "2026-12-24T09:30:00Z"),json. Append "?" for optional ("string?"). Use the object form to give a default:{ "type": "number", "default": 1 }. Field names: letters, digits, underscore, starting with a lowercase letter; "id" and names starting with "_" are reserved. - index: fields you filter or sort by (max 5, not json).
id,_createdAtand_createdByare always indexed. - read: "members" (every member sees all records) or "own" (members see only records they created).
- write: "members" (every member may change any record), "own" (members may add records and change/delete only their own; default), "admins" (only admins write).
- admins (top level): e-mail addresses with all rights in this app; they can log in without being on the member list. Per collection, "admins" adds admins for that collection only.
- connect: extra hosts the browser may contact, e.g. ["api.example.com"]. Leave empty unless really needed; the owner sees this list.
- Every record also has
id,_createdBy(e-mail),_createdAtand_updatedAt(milliseconds), set by the platform. - Changing the manifest later: adding collections, optional fields or indexes is safe. Adding a required field to a collection that already has records needs a "default". Deleting a field or collection, or changing a field type, destroys data: the deploy is refused unless you pass allowDataLoss: true (a snapshot is taken first). Never do that without asking the owner.
SDK: /_hubps/sdk.js
Include it in every HTML page that uses data or the current user: <script src="/_hubps/sdk.js"></script>. It defines window.hubps.
const me = await hubps.user(); // { email, role: "member" | "admin" } (null in public apps)
const requests = hubps.collection('requests');
const list = await requests.list({ // array of records, with list.nextCursor
where: { status: 'open', from: { gte: '2026-12-01' } },
order: '-from', // "-" = descending; default is creation order
limit: 50, // max 200
cursor: undefined, // pass list.nextCursor for the next page
});
const all = await requests.listAll({ where: { status: 'open' } }); // follows cursors, up to 5000 records
const one = await requests.get(id);
const created = await requests.insert({ from: '2026-12-24', to: '2026-12-31', status: 'open' });
const updated = await requests.update(created.id, { status: 'approved' }); // partial update; null clears an optional field
await requests.remove(created.id);
const live = requests.subscribe((items) => render(items), { where: { status: 'open' }, interval: 5000 }); // polling; live() ends it, live.refresh() polls now (call it after your own changes)
await hubps.logout();
Filter operators in where: plain value (equals), or an object with eq, ne, lt, lte, gt, gte, in (array, max 50), contains (text search on string fields). All conditions are combined with AND. null matches empty values.
Errors are thrown as HubpsError with status, code and a message that says what to do. Show the message to the user. Unhandled errors in the page are reported to the platform automatically; get_logs shows them.
Rules for good apps:
- Always call the SDK from the page, never fetch other origins (blocked).
- Escape user content before putting it into the page (use textContent or a safe template), because records contain text from colleagues.
- Do not store data in localStorage that colleagues must share; use collections.
- Keep the UI usable on a phone. Use simple, dependency-free code where possible.
Access
- New apps are private (mode "members"): only the listed e-mail addresses (and admins from hubps.json) can open them. They sign in with a link sent to their address; there is no password.
set_accessacceptsmembers(replace the whole list),add_members,remove_members. Removing a person ends their session at once. The number of members per app is limited by the plan.- Mode "public" serves the files to everyone without login. Public apps have no data API and no user: use them only for static content. Switching to public is a deliberate choice; ask the owner first.
- The owner's own address is not added automatically.
Example: vacation planner for four people
Files: hubps.json and index.html. Deploy both with one deploy call, then set_access.
hubps.json:
{
"name": "Vacation planner",
"admins": ["boss@company.com"],
"collections": {
"requests": {
"fields": { "from": "date", "to": "date", "status": "string", "note": "string?" },
"index": ["status", "from"],
"read": "members",
"write": "own"
}
}
}
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vacation planner</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
li { display: flex; gap: .5rem; align-items: baseline; padding: .25rem 0; }
li span.who { color: #666; }
</style>
</head>
<body>
<h1>Vacation planner</h1>
<p id="me"></p>
<form id="form">
<input type="date" name="from" required> to <input type="date" name="to" required>
<input type="text" name="note" placeholder="Note (optional)">
<button>Request</button>
</form>
<ul id="list"></ul>
<script src="/_hubps/sdk.js"></script>
<script>
const requests = hubps.collection('requests');
const list = document.getElementById('list');
let me;
let live;
function render(items) {
list.textContent = '';
for (const r of items) {
const li = document.createElement('li');
const text = document.createElement('span');
text.textContent = r.from + ' to ' + r.to + (r.note ? ' (' + r.note + ')' : '') + ' - ' + r.status;
const who = document.createElement('span');
who.className = 'who';
who.textContent = r._createdBy;
li.append(text, who);
if (me.role === 'admin' && r.status === 'open') {
const ok = document.createElement('button');
ok.textContent = 'Approve';
ok.onclick = () => requests.update(r.id, { status: 'approved' }).then(() => live.refresh()).catch((e) => alert(e.message));
li.append(ok);
}
if (r._createdBy === me.email) {
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => requests.remove(r.id).then(() => live.refresh()).catch((e) => alert(e.message));
li.append(del);
}
list.append(li);
}
}
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
const f = new FormData(e.target);
try {
await requests.insert({ from: f.get('from'), to: f.get('to'), note: f.get('note') || null, status: 'open' });
e.target.reset();
live.refresh();
} catch (err) { alert(err.message); }
});
hubps.user().then((u) => {
me = u;
document.getElementById('me').textContent = 'Signed in as ' + u.email;
live = requests.subscribe(render, { order: '-from', interval: 4000 });
});
</script>
</body>
</html>