Add a CRUD Feature
This tutorial walks through adding a completely new feature to your Varity app — from defining the data model to building a working page with create, read, update, and delete operations.
We’ll add a Notes feature: a page where users can create, edit, and delete notes.
Prerequisites
Section titled “Prerequisites”A running Varity project (from the SaaS Starter template or manual setup):
cd my-appnpm run devThe Pattern
Section titled “The Pattern”Every feature follows 4 steps:
1. Type → 2. Collection → 3. Hook → 4. PageLet’s build each one.
Step 1: Define the Type
Section titled “Step 1: Define the Type”Add a Note interface to your types file:
// Add this to your existing typesexport interface Note { id?: string; title: string; content: string; color: 'yellow' | 'blue' | 'green' | 'pink'; createdAt: string; updatedAt: string;}Step 2: Create the Collection
Section titled “Step 2: Create the Collection”Add a collection accessor in your database file:
import { db } from './varity';import type { Project, Task, TeamMember, Note } from '../types';
export const projects = () => db.collection<Project>('projects');export const tasks = () => db.collection<Task>('tasks');export const teamMembers = () => db.collection<TeamMember>('team_members');export const notes = () => db.collection<Note>('notes'); // Add this lineStep 3: Build the Hook
Section titled “Step 3: Build the Hook”Add a useNotes hook to your hooks file. This provides loading states, error handling, and optimistic updates:
import { notes } from './database';import type { Note } from '../types';
export function useNotes() { // Component state const [data, setData] = useState<Note[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null);
// Fetch all notes from database const refresh = useCallback(async () => { try { setLoading(true); setError(null); const result = await notes().get(); // Get all notes (no server-side filtering) setData(result as Note[]); } catch (err) { // Handle errors gracefully setError(err instanceof Error ? err.message : 'Failed to load notes'); console.error('Failed to load notes:', err); } finally { setLoading(false); } }, []);
// Load notes on component mount useEffect(() => { refresh(); }, [refresh]);
// Create note with optimistic UI update const create = async (input: Omit<Note, 'id' | 'createdAt' | 'updatedAt'>) => { const now = new Date().toISOString(); // Create temporary note for instant UI feedback const optimistic: Note = { ...input, id: `temp-${Date.now()}`, // Temporary ID (will be replaced by real ID) createdAt: now, updatedAt: now, }; // Add to UI immediately (before database confirms) setData(prev => [optimistic, ...prev]);
try { // Save to database await notes().add({ ...input, createdAt: now, updatedAt: now }); // Refresh to get real ID from server await refresh(); } catch (err) { // Rollback on error - remove the optimistic item setData(prev => prev.filter(n => n.id !== optimistic.id)); console.error('Failed to create note:', err); throw err; // Re-throw so UI can show error message } };
const update = async (id: string, updates: Partial<Note>) => { const original = data.find(n => n.id === id); const withTimestamp = { ...updates, updatedAt: new Date().toISOString() }; setData(prev => prev.map(n => n.id === id ? { ...n, ...withTimestamp } : n));
try { await notes().update(id, withTimestamp); } catch (err) { if (original) setData(prev => prev.map(n => n.id === id ? original : n)); throw err; } };
const remove = async (id: string) => { const original = data.find(n => n.id === id); setData(prev => prev.filter(n => n.id !== id));
try { await notes().delete(id); } catch (err) { if (original) setData(prev => [...prev, original]); throw err; } };
return { data, loading, error, create, update, remove, refresh };}Step 4: Build the Page
Section titled “Step 4: Build the Page”Create the page component:
'use client';
import { useState } from 'react';import { useNotes } from '../../../lib/hooks';import type { Note } from '../../../types';
const COLORS = ['yellow', 'blue', 'green', 'pink'] as const;
export default function NotesPage() { const { data: notes, loading, error, create, update, remove } = useNotes(); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState<string | null>(null);
if (loading) return <div style={{ padding: '2rem' }}>Loading notes...</div>; if (error) return <div style={{ padding: '2rem' }}>Error: {error}</div>;
return ( <div style={{ padding: '2rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}> <h1 style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>Notes ({notes.length})</h1> <button onClick={() => setShowForm(true)} style={{ padding: '0.5rem 1rem', backgroundColor: 'var(--color-primary-500, #3b82f6)', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer', }} > New Note </button> </div>
{showForm && ( <NoteForm onSubmit={async (note) => { await create(note); setShowForm(false); }} onCancel={() => setShowForm(false)} /> )}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}> {notes.map(note => ( <NoteCard key={note.id} note={note} isEditing={editingId === note.id} onEdit={() => setEditingId(note.id!)} onUpdate={async (updates) => { await update(note.id!, updates); setEditingId(null); }} onDelete={() => remove(note.id!)} onCancelEdit={() => setEditingId(null)} /> ))} </div> </div> );}
function NoteForm({ onSubmit, onCancel, initial,}: { onSubmit: (note: Omit<Note, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>; onCancel: () => void; initial?: Partial<Note>;}) { const [title, setTitle] = useState(initial?.title || ''); const [content, setContent] = useState(initial?.content || ''); const [color, setColor] = useState<Note['color']>(initial?.color || 'yellow');
return ( <div style={{ marginBottom: '1.5rem', padding: '1rem', border: '1px solid #e5e7eb', borderRadius: '0.5rem' }}> <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Note title" style={{ display: 'block', width: '100%', marginBottom: '0.5rem', padding: '0.5rem', border: '1px solid #d1d5db', borderRadius: '0.25rem' }} /> <textarea value={content} onChange={e => setContent(e.target.value)} placeholder="Write your note..." rows={3} style={{ display: 'block', width: '100%', marginBottom: '0.5rem', padding: '0.5rem', border: '1px solid #d1d5db', borderRadius: '0.25rem' }} /> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}> {COLORS.map(c => ( <button key={c} onClick={() => setColor(c)} style={{ width: '24px', height: '24px', borderRadius: '50%', border: color === c ? '2px solid #333' : '1px solid #ccc', backgroundColor: c === 'yellow' ? '#fef9c3' : c === 'blue' ? '#dbeafe' : c === 'green' ? '#dcfce7' : '#fce7f3', cursor: 'pointer', }} /> ))} </div> <div style={{ display: 'flex', gap: '0.5rem' }}> <button onClick={() => onSubmit({ title, content, color })} style={{ padding: '0.5rem 1rem', backgroundColor: 'var(--color-primary-500, #3b82f6)', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer' }}> Save </button> <button onClick={onCancel} style={{ padding: '0.5rem 1rem', border: '1px solid #d1d5db', borderRadius: '0.25rem', cursor: 'pointer' }}> Cancel </button> </div> </div> );}
function NoteCard({ note, isEditing, onEdit, onUpdate, onDelete, onCancelEdit,}: { note: Note; isEditing: boolean; onEdit: () => void; onUpdate: (updates: Partial<Note>) => Promise<void>; onDelete: () => void; onCancelEdit: () => void;}) { const bgColor = note.color === 'yellow' ? '#fef9c3' : note.color === 'blue' ? '#dbeafe' : note.color === 'green' ? '#dcfce7' : '#fce7f3';
if (isEditing) { return ( <NoteForm initial={note} onSubmit={async (updates) => onUpdate(updates)} onCancel={onCancelEdit} /> ); }
return ( <div style={{ padding: '1rem', backgroundColor: bgColor, borderRadius: '0.5rem', position: 'relative' }}> <h3 style={{ fontWeight: '600', marginBottom: '0.5rem' }}>{note.title}</h3> <p style={{ fontSize: '0.875rem', color: '#4b5563' }}>{note.content}</p> <div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem' }}> <button onClick={onEdit} style={{ fontSize: '0.75rem', color: '#6b7280', cursor: 'pointer', border: 'none', background: 'none' }}> Edit </button> <button onClick={onDelete} style={{ fontSize: '0.75rem', color: '#ef4444', cursor: 'pointer', border: 'none', background: 'none' }}> Delete </button> </div> </div> );}Step 5: Add to Navigation
Section titled “Step 5: Add to Navigation”Edit src/lib/constants.ts:
export const NAVIGATION_ITEMS = [ { label: 'Dashboard', icon: 'dashboard', path: '/dashboard' }, { label: 'Projects', icon: 'folder', path: '/dashboard/projects' }, { label: 'Tasks', icon: 'list', path: '/dashboard/tasks' }, { label: 'Notes', icon: 'list', path: '/dashboard/notes' }, // Add this { label: 'Team', icon: 'people', path: '/dashboard/team' }, { label: 'Settings', icon: 'settings', path: '/dashboard/settings' },];Step 6: Test
Section titled “Step 6: Test”- Run
npm run dev - Navigate to
/dashboard/notes - Create a note, edit it, change its color, delete it
- Refresh the page — data persists
Step 7: Deploy
Section titled “Step 7: Deploy”npm run buildvaritykit app deployRecap: The 4-Step Pattern
Section titled “Recap: The 4-Step Pattern”| Step | File | What You Do |
|---|---|---|
| 1. Type | src/types/index.ts | Define a TypeScript interface |
| 2. Collection | src/lib/database.ts | Add db.collection<T>('name') |
| 3. Hook | src/lib/hooks.ts | Build React hook with CRUD + optimistic updates |
| 4. Page | src/app/dashboard/*/page.tsx | Build the UI using the hook |
This pattern scales to any feature. Every page in the SaaS Starter template follows it.
Next Steps
Section titled “Next Steps”- Database Quick Start — Full database API reference
- SaaS Template Reference — Complete template documentation
- Deploy — Deployment options