apphaven example apps
1. Personal to-do list (static, no build)
Apphaven supplies the accounts and database for this example. In preview, mail to anyone other
than the owner's Apphaven account email address is captured in read_emails. Test the form's
sign-up, confirmation link and sign-in without as using "Testing sign-in in preview" in
get_docs topic sdk; as: "alice" already has a confirmed address and is for checking tasks
and access rules afterward.
apphaven/app.yaml
app: { name: My Tasks }
models:
Task:
fields:
title: { type: string, required: true, max_length: 200 }
done: { type: boolean, default: false }
due: date
owner: { type: user, default: $user.id, readonly: true, required: true }
access:
read: owner == $user.id
create: $user != null
update: owner == $user.id
delete: owner == $user.id
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Tasks</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main id="root"></main>
<script type="module" src="/app.js"></script>
</body>
</html>
app.js
import { createClient } from "/_apphaven/sdk.js";
const app = createClient();
const root = document.getElementById("root");
app.auth.onChange((user) => (user ? showTasks(user) : showSignIn()));
function showSignIn(message = "") {
root.innerHTML = `
<h1>My Tasks</h1>
<form id="f">
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password (8+ characters)" required minlength="8" />
<button name="mode" value="in">Sign in</button>
<button name="mode" value="up">Create account</button>
<p class="message"></p>
</form>`;
root.querySelector(".message").textContent = message;
const form = root.querySelector("#f");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const { email, password } = Object.fromEntries(new FormData(form));
try {
if (e.submitter.value === "up") {
await app.auth.signUp({ email, password }); // no session yet: the address is confirmed first
showSignIn("Check your email for a confirmation link, then sign in.");
} else await app.auth.signIn({ email, password });
} catch (err) {
showSignIn(err.message);
}
});
}
async function showTasks(user) {
const { items } = await app.db.Task.list({ sort: "done,-created_at" });
root.innerHTML = `
<h1>${escape(user.name)}'s tasks <button id="out">Sign out</button></h1>
<form id="add"><input name="title" placeholder="New task" required /><button>Add</button></form>
<ul>${items.map((t) => `<li><label><input type="checkbox" data-id="${t.id}" ${t.done ? "checked" : ""}/> ${escape(t.title)}</label></li>`).join("")}</ul>`;
root.querySelector("#out").onclick = () => app.auth.signOut();
root.querySelector("#add").onsubmit = async (e) => {
e.preventDefault();
await app.db.Task.create({ title: new FormData(e.target).get("title") });
showTasks(user);
};
root.querySelectorAll("input[type=checkbox]").forEach((box) => {
box.onchange = () => app.db.Task.update(box.dataset.id, { done: box.checked });
});
}
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
2. Vite + React project
package.json
{
"name": "recipes",
"private": true,
"type": "module",
"scripts": { "build": "vite build" },
"dependencies": { "@apphaven/client": "*", "react": "^19.0.0", "react-dom": "^19.0.0" },
"devDependencies": { "@vitejs/plugin-react": "^5.0.0", "vite": "^7.0.0" }
}
vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
index.html (project root) loads /src/main.jsx; in components:
import { createClient } from "@apphaven/client";
export const app = createClient();
function useUser() {
const [user, setUser] = useState(undefined); // undefined = loading
useEffect(() => app.auth.onChange(setUser), []);
return user;
}
Tailwind: add tailwindcss and @tailwindcss/vite as devDependencies, tailwindcss() to the
Vite plugins, and @import "tailwindcss"; in your CSS.
Installable (on the user's phone)
Add "vite-plugin-pwa": "^1.0.0" to devDependencies and extend vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate", // a deploy reaches installed phones on their next open
manifest: {
name: "Kiln Club", short_name: "Kiln", description: "Bookings and firing schedule",
start_url: "/", display: "standalone", background_color: "#ffffff", theme_color: "#1a1a1a",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
],
},
workbox: {
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api/, /^\/_apphaven/], // sign-in and data never go through the cache
},
}),
],
});
The icons live in public/icons/ (served at /icons/...): square PNGs made from the user's logo
(ask for it through the Add files page) or a simple drawing of your own. The build emits
manifest.webmanifest, sw.js and registerSW.js into dist/; nothing else to do. Check it
with view_page on a phone, then tell the user to install it from the live address: iPhone,
Safari, Share, "Add to Home Screen"; Android, "Install app" in the browser's menu.
Plain pages next to the app
public/about.html and public/prices.html are served as-is at /about and /prices (fast,
readable by search engines) while the Vite app answers /. Link between them with plain paths.
3. Public guestbook with moderation
auth:
roles: [user, admin]
models:
Entry:
fields:
name: { type: string, required: true, max_length: 60 }
message: { type: text, required: true, max_length: 1000 }
approved: { type: boolean, default: false, readonly: true }
access:
create: true # anyone, even signed out
read: approved == true || $user.role == 'admin'
delete: $user.role == 'admin'
operations:
approveEntry:
model: Entry
action: update
params: { id: string }
where: id == $params.id
set: { approved: true }
access: $user.role == 'admin'
After the owner signs up in the app, make them an admin with manage_users (action: set_role).