Your first app
In this tutorial you will build QuickNotes — a simple notes app where users sign up, create notes stored in Postgres, get a greeting from a serverless function, and browse the app from a deployed static site.
Estimated time: 20–30 minutes.
What you will build
Section titled “What you will build”| Layer | Technology | Reactor capability |
|---|---|---|
| Auth | Email + password sign-up | Identity |
| Database | notes table with RLS | Data |
| API logic | summarize-note function | Functions |
| Frontend | Static HTML/JS in sites/web | Sites |
Final architecture:
Browser → Sites (static) → Identity (login) → Data (notes CRUD) → Functions (summarize-note)Step 1 — Create the project
Section titled “Step 1 — Create the project”reactor init quicknotescd quicknotesreactor devKeep reactor dev running in one terminal. All remaining commands run in a second terminal from quicknotes/.
Step 2 — Define the database schema
Section titled “Step 2 — Define the database schema”Replace the sample migration with a notes schema. Create data/migrations/001_notes.sql:
-- Notes table with row-level securityCREATE TABLE notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE INDEX notes_user_id_idx ON notes (user_id);
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- Users can only see and modify their own notesCREATE POLICY notes_select ON notes FOR SELECT USING (user_id = current_setting('reactor.user_id', true)::uuid);
CREATE POLICY notes_insert ON notes FOR INSERT WITH CHECK (user_id = current_setting('reactor.user_id', true)::uuid);
CREATE POLICY notes_update ON notes FOR UPDATE USING (user_id = current_setting('reactor.user_id', true)::uuid);
CREATE POLICY notes_delete ON notes FOR DELETE USING (user_id = current_setting('reactor.user_id', true)::uuid);Apply the migration:
reactor data migratereactor data inspect notesStep 3 — Register the function in reactor.toml
Section titled “Step 3 — Register the function in reactor.toml”Open reactor.toml and add your function and site entries:
project_id = "quicknotes-xxxxxxxx"name = "QuickNotes"default_context = "local"
[data]migrations_dir = "data/migrations"
[[functions]]name = "summarize-note"source = "functions/summarize-note"runtime = "wasm"entry = "index.ts"
[[sites]]name = "web"source = "sites/web"framework = "static"Step 4 — Write the summarize function
Section titled “Step 4 — Write the summarize function”Create functions/summarize-note/index.ts:
// Summarize a note body — invoked at POST /fn/v1/summarize-noteexport async function handler(request: Request): Promise<Response> { const { body } = await request.json(); const text = typeof body === 'string' ? body : body?.text ?? '';
if (!text.trim()) { return new Response(JSON.stringify({ error: 'text is required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); }
// Simple summary: first sentence or first 120 characters const sentence = text.split(/[.!?]/)[0]?.trim() || text; const summary = sentence.length > 120 ? sentence.slice(0, 117) + '...' : sentence;
return new Response(JSON.stringify({ summary, length: text.length }), { status: 200, headers: { 'Content-Type': 'application/json' }, });}Test it locally:
reactor functions invoke summarize-note \ --body '{"text": "Reactor makes backend development simple. You get auth, data, and functions in one place."}'curl -X POST http://localhost:8080/fn/v1/summarize-note \ -H "Content-Type: application/json" \ -d '{"text": "Reactor makes backend development simple."}'Step 5 — Build the frontend
Section titled “Step 5 — Build the frontend”Create a minimal static site at sites/web/index.html:
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>QuickNotes</title> <style> body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; } input, textarea, button { display: block; width: 100%; margin: 0.5rem 0; padding: 0.5rem; } .note { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin: 1rem 0; } .hidden { display: none; } </style></head><body> <h1>QuickNotes</h1>
<section id="auth"> <h2>Sign in</h2> <input id="email" type="email" placeholder="Email" /> <input id="password" type="password" placeholder="Password" /> <button id="signup">Sign up</button> <button id="login">Log in</button> </section>
<section id="app" class="hidden"> <p>Signed in as <strong id="user-email"></strong> <button id="logout">Log out</button></p> <h2>New note</h2> <input id="title" placeholder="Title" /> <textarea id="body" rows="4" placeholder="Body"></textarea> <button id="save">Save note</button> <h2>Your notes</h2> <div id="notes"></div> </section>
<script type="module"> import { createClient } from 'https://esm.sh/@reactor/client';
const reactor = createClient(window.location.origin, { key: 'rk_pub_local_dev', // replace with your project key in production });
const authEl = document.getElementById('auth'); const appEl = document.getElementById('app'); const notesEl = document.getElementById('notes');
async function refreshUI() { const session = await reactor.auth.getSession(); if (session?.user) { authEl.classList.add('hidden'); appEl.classList.remove('hidden'); document.getElementById('user-email').textContent = session.user.email; await loadNotes(); } else { authEl.classList.remove('hidden'); appEl.classList.add('hidden'); } }
async function loadNotes() { const { data } = await reactor.from('notes').select('*').order('created_at', { ascending: false }); notesEl.innerHTML = ''; for (const note of data ?? []) { const div = document.createElement('div'); div.className = 'note'; div.innerHTML = `<h3>${note.title}</h3><p>${note.body}</p>`; notesEl.appendChild(div); } }
document.getElementById('signup').onclick = async () => { await reactor.auth.signUp({ email: document.getElementById('email').value, password: document.getElementById('password').value, }); await refreshUI(); };
document.getElementById('login').onclick = async () => { await reactor.auth.signIn({ email: document.getElementById('email').value, password: document.getElementById('password').value, }); await refreshUI(); };
document.getElementById('logout').onclick = async () => { await reactor.auth.signOut(); await refreshUI(); };
document.getElementById('save').onclick = async () => { const session = await reactor.auth.getSession(); await reactor.from('notes').insert({ user_id: session.user.id, title: document.getElementById('title').value, body: document.getElementById('body').value, }); document.getElementById('title').value = ''; document.getElementById('body').value = ''; await loadNotes(); };
refreshUI(); </script></body></html>Step 6 — Test auth and data
Section titled “Step 6 — Test auth and data”Sign up and create a note using the SDK in a small script, or use reactor login after creating a user through the UI.
# Sign upcurl -X POST http://localhost:8080/auth/v1/signup \ -H "Content-Type: application/json" \ -d '{"email": "demo@example.com", "password": "secure-password-123"}'
# Log in (save the access_token from the response)curl -X POST http://localhost:8080/auth/v1/login \ -H "Content-Type: application/json" \ -d '{"email": "demo@example.com", "password": "secure-password-123"}'
# Insert a note (replace TOKEN and USER_ID)curl -X POST http://localhost:8080/data/v1/notes \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '{"user_id": "USER_ID", "title": "Hello", "body": "My first note."}'
# List notescurl http://localhost:8080/data/v1/notes \ -H "Authorization: Bearer TOKEN"import { createClient } from '@reactor/client';
const reactor = createClient('http://localhost:8080');
await reactor.auth.signUp({ email: 'demo@example.com', password: 'secure-password-123',});
const session = await reactor.auth.getSession();
await reactor.from('notes').insert({ user_id: session!.user.id, title: 'Hello', body: 'My first note.',});
const { data } = await reactor.from('notes').select('*');console.log(data);
const summary = await reactor.functions.invoke('summarize-note', { body: { text: data![0].body },});console.log(summary.data);Open the site in your browser. During local dev, static files are typically served at the project site path — check reactor dev output for the exact URL.
Step 7 — Deploy
Section titled “Step 7 — Deploy”Add a production context if you have not already:
reactor context add production \ --endpoint https://api.reactor.cloud \ --org my-org
reactor context use productionreactor loginBuild and deploy:
reactor buildreactor deployReactor will:
- Validate
reactor.tomland bundle functions, migrations, and site assets. - Apply migrations on the target database.
- Publish
summarize-noteto the Functions runtime. - Upload
sites/webto Sites hosting.
Check deployment status:
reactor sites show webreactor functions show summarize-noteStep 8 — Verify production
Section titled “Step 8 — Verify production”reactor functions invoke summarize-note \ --context production \ --body '{"text": "Deployed and working!"}'
reactor sites domains list webcurl -X POST https://api.reactor.cloud/fn/v1/summarize-note \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text": "Deployed and working!"}'Visit your site URL (printed by reactor sites show web) and confirm sign-up, note creation, and listing work end to end.
Extend the app
Section titled “Extend the app”Once the basics work, try these additions:
| Idea | Capability | Starting point |
|---|---|---|
| Auto-summarize on save | Jobs | Trigger summarize-note after note insert |
| Avatar uploads | Storage | reactor.storage.from('avatars').upload(...) |
| AI-powered summaries | Gateway | Route to an LLM instead of truncating text |
| Slack notifications | Connect | Webhook on new note creation |
Troubleshooting
Section titled “Troubleshooting”| Problem | Solution |
|---|---|
| Notes not visible after insert | Confirm RLS policies and that user_id matches the authenticated user |
| Function 404 | Check [[functions]] name in reactor.toml matches the directory |
| Site shows blank page | Verify sites/web/index.html exists and framework = "static" |
| Deploy auth error | Run reactor login and reactor whoami against the target context |