Tutorial: a to-do app with login in one HTML file
Sign up, sign in and private tasks per user, using the Owner access rule and about 60 lines of JavaScript.
We'll build a complete to-do app: users register, sign in and manage tasks that nobody else can see. No framework, no build step, one HTML file.
1. Prepare the project
In your project:
- Authentication tab → check Enable login and Allow public sign up, then Save.
- Tables → New table named
todoswith columns:title—text, required,done—boolean, defaultfalse.
- API access: Owner — each user only sees and edits their own rows.
The Owner rule adds an owner_id column that falcondatabase fills in for you.
2. The page
Save this as todo.html, replace PROJECT_REF and ANON_KEY, and open it with any static server (for example python3 -m http.server).
<!doctype html>
<html>
<body>
<form id="auth">
<input id="email" type="email" placeholder="email" required>
<input id="password" type="password" placeholder="password" required minlength="6">
<button name="action" value="in">Sign in</button>
<button name="action" value="up">Sign up</button>
</form>
<section id="app" hidden>
<p>Hi <b id="who"></b> · <button id="logout">Sign out</button></p>
<form id="new"><input id="title" placeholder="New task" required> <button>Add</button></form>
<ul id="list"></ul>
</section>
<script type="module">
import { createClient } from "http://falcondatabase.com/sdk/falcondb.js"
const db = createClient("http://falcondatabase.com", "PROJECT_REF", "ANON_KEY")
const $ = (id) => document.getElementById(id)
async function render() {
const signedIn = Boolean(db.auth.user)
$("auth").hidden = signedIn
$("app").hidden = !signedIn
if (!signedIn) return
$("who").textContent = db.auth.user.email
const { data } = await db.from("todos").select().order("created_at")
$("list").innerHTML = ""
for (const todo of data) {
const li = document.createElement("li")
li.innerHTML = `<input type="checkbox" ${todo.done ? "checked" : ""}> <span></span> <button>✕</button>`
li.querySelector("span").textContent = todo.title
li.querySelector("input").onchange = (e) => db.from("todos").update(todo.id, { done: e.target.checked })
li.querySelector("button").onclick = async () => { await db.from("todos").delete(todo.id); render() }
$("list").append(li)
}
}
$("auth").onsubmit = async (event) => {
event.preventDefault()
const credentials = { email: $("email").value, password: $("password").value }
const action = event.submitter.value === "up" ? db.auth.signUp : db.auth.signIn
const { error } = await action.call(db.auth, credentials)
if (error) alert(error.message)
}
$("new").onsubmit = async (event) => {
event.preventDefault()
await db.from("todos").insert({ title: $("title").value })
$("title").value = ""
render()
}
$("logout").onclick = () => db.auth.signOut()
db.auth.onChange(render) // re-render on sign in / sign out
render()
</script>
</body>
</html>
3. What just happened
signUpandsignInstore the session inlocalStorage, so a reload keeps you signed in.- Every request after that carries
Authorization: Bearer <token>automatically. insert({ title })never sendsowner_id; the server sets it from the token.select()only returns the current user's rows. Open the page in a private window with another account: the lists are separate.
4. Check it from the dashboard
Open the todos table in the dashboard: you see every task with its owner_id. The Authentication tab lists the users, their last login, and lets you deactivate an account. A deactivated user's token stops working immediately.
Ideas to extend it
- Store a display name at sign up:
db.auth.signUp({ email, password, data: { name } }). - Update it later with
db.auth.updateUser({ name: "Ana" }). - Add a
due_datecolumn of typedateand sort with.order("due_date").