Skip to content

Add a CRUD Feature

Varity Team Core Contributors Updated March 2026

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.

A running Varity project (from the SaaS Starter template or manual setup):

Terminal window
cd my-app
npm run dev

Every feature follows 4 steps:

1. Type → 2. Collection → 3. Hook → 4. Page

Let’s build each one.

Add a Note interface to your types file:

src/types/index.ts
// Add this to your existing types
export interface Note {
id?: string;
title: string;
content: string;
color: 'yellow' | 'blue' | 'green' | 'pink';
createdAt: string;
updatedAt: string;
}

Add a collection accessor in your database file:

src/lib/database.ts
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 line

Add a useNotes hook to your hooks file. This provides loading states, error handling, and optimistic updates:

src/lib/hooks.ts
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 };
}

Create the page component:

src/app/dashboard/notes/page.tsx
'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>
);
}

Edit src/lib/constants.ts:

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' },
];
  1. Run npm run dev
  2. Navigate to /dashboard/notes
  3. Create a note, edit it, change its color, delete it
  4. Refresh the page — data persists
Terminal window
npm run build
varitykit app deploy
StepFileWhat You Do
1. Typesrc/types/index.tsDefine a TypeScript interface
2. Collectionsrc/lib/database.tsAdd db.collection<T>('name')
3. Hooksrc/lib/hooks.tsBuild React hook with CRUD + optimistic updates
4. Pagesrc/app/dashboard/*/page.tsxBuild the UI using the hook

This pattern scales to any feature. Every page in the SaaS Starter template follows it.