PricingPrivacyTermsSign in

Connect your agent › The agent's guide

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).

HomeDocsPricingPrivacyTermsSupportReport content A product of Efficacious, Inc.