Tutorial: Build a TODO App
The classic TODO app tutorial, except this one actually teaches you something useful. Models, relations, CRUD, filtering, and a PostgreSQL migration. About 15 minutes, or less if you type fast.
What you'll build
By the end of this tutorial you'll have a working TODO application with:
- Projects that group related tasks
- Tasks with titles, priorities, due dates, and completion status
- Tags you can attach to any task (many-to-many)
- Full CRUD (create, read, update, and delete)
- Filtering, sorting, and pagination
- Relations between models with eager loading
No database setup needed. We start with a JSON file like civilized people, and optionally export to PostgreSQL at the end when you're feeling ambitious.
Prerequisites
- Node.js 18 or later
- Basic familiarity with TypeScript (or JavaScript, the concepts are the same)
Step 1: Project setup
Create a new directory and initialize both npm and SeedORM:
$ mkdir todo-app && cd todo-app$ npm init -y$ npm install seedorm tsx$ npx seedorm init✓ Created seedorm.config.json✓ Created data/ directory✓ Created migrations/ directoryWe're installing tsx so we can run TypeScript directly without a build step, because life is too short for build configs. SeedORM defaults to the JSON adapter, so your data lives in data/seedorm.json. No database, no Docker, no problems.
Step 2: Connect to SeedORM
Create an app.ts file. This will be our main entry point.
1import { SeedORM, FieldType, RelationType } from "seedorm";23async function main() {4 const db = new SeedORM();5 await db.connect();67 console.log("Connected to SeedORM!");89 // ... we'll add models and logic here ...1011 await db.disconnect();12}1314main();Run it to make sure everything is wired up:
$ npx tsx app.tsConnected to SeedORM!Step 3: Define the Project model
A Project groups related tasks together. Think of it as a folder for your TODOs. Add the following inside your main() function, after db.connect():
1const Project = db.model({2 name: "Project",3 collection: "projects",4 schema: {5 name: { type: FieldType.String, required: true, minLength: 1 },6 description: { type: FieldType.String, default: "" },7 color: { type: FieldType.String, default: "#6366f1" },8 },9 relations: {10 tasks: {11 type: RelationType.HasMany,12 model: "Task",13 foreignKey: "projectId",14 },15 },16 prefix: "prj",17});1819await Project.init();A few things to notice:
FieldType.Stringkeeps field types type-safe with no typos.- The
relationsobject declares that a Project has many Tasks, linked byprojectIdon the Task side. - The
prefixgives generated IDs a human-readable prefix likeprj_V1StGXR8.
Step 4: Define the Task model
Tasks are the core of any TODO app. Each task belongs to a project and can have multiple tags.
1const Task = db.model({2 name: "Task",3 collection: "tasks",4 schema: {5 title: { type: FieldType.String, required: true, minLength: 1 },6 completed: { type: FieldType.Boolean, default: false },7 priority: { type: FieldType.String, enum: ["low", "medium", "high"], default: "medium" },8 dueDate: { type: FieldType.Date },9 projectId: { type: FieldType.String, required: true },10 notes: { type: FieldType.String, default: "" },11 },12 relations: {13 project: {14 type: RelationType.BelongsTo,15 model: "Project",16 foreignKey: "projectId",17 },18 tags: {19 type: RelationType.ManyToMany,20 model: "Tag",21 joinCollection: "task_tags",22 foreignKey: "taskId",23 relatedKey: "tagId",24 },25 },26 prefix: "tsk",27});2829await Task.init();The Task model introduces two new relation types:
belongsTois the inverse of the Project'shasMany. The foreign key (projectId) lives on this model.manyToManymeans a task can have many tags, and a tag can belong to many tasks. SeedORM uses atask_tagsjoin collection behind the scenes.
Step 5: Define the Tag model
Tags are simple labels like "urgent", "bug", or "feature" that you can attach to any task.
1const Tag = db.model({2 name: "Tag",3 collection: "tags",4 schema: {5 name: { type: FieldType.String, required: true, unique: true },6 color: { type: FieldType.String, default: "#94a3b8" },7 },8 relations: {9 tasks: {10 type: RelationType.ManyToMany,11 model: "Task",12 joinCollection: "task_tags",13 foreignKey: "tagId",14 relatedKey: "taskId",15 },16 },17 prefix: "tag",18});1920await Tag.init();Notice the Tag's manyToMany mirrors the Task's: same joinCollection, but foreignKey and relatedKey are swapped.
Step 6: Seed some data
Let's populate the app with sample data so we have something to work with.
1// Create a project2const work = await Project.create({3 name: "Work",4 description: "Office tasks and deadlines",5 color: "#ef4444",6});78const personal = await Project.create({9 name: "Personal",10 description: "Life stuff",11 color: "#22c55e",12});1314console.log("Created projects:", work.id, personal.id);1516// Create tags17const urgent = await Tag.create({ name: "urgent", color: "#dc2626" });18const feature = await Tag.create({ name: "feature", color: "#2563eb" });19const bug = await Tag.create({ name: "bug", color: "#ea580c" });2021console.log("Created tags:", urgent.id, feature.id, bug.id);2223// Create tasks24const task1 = await Task.create({25 title: "Finish quarterly report",26 priority: "high",27 dueDate: new Date("2025-04-01").toISOString(),28 projectId: work.id,29});3031const task2 = await Task.create({32 title: "Fix login page bug",33 priority: "high",34 projectId: work.id,35 notes: "Users can't log in on Safari",36});3738const task3 = await Task.create({39 title: "Buy groceries",40 priority: "low",41 projectId: personal.id,42});4344const task4 = await Task.create({45 title: "Plan weekend trip",46 priority: "medium",47 dueDate: new Date("2025-03-20").toISOString(),48 projectId: personal.id,49});5051console.log("Created 4 tasks");5253// Tag the tasks54await Task.associate(task1.id, "tags", urgent.id);55await Task.associate(task2.id, "tags", urgent.id);56await Task.associate(task2.id, "tags", bug.id);57await Task.associate(task4.id, "tags", feature.id);5859console.log("Tagged tasks");Run it:
$ npx tsx app.tsConnected to SeedORM!Created projects: prj_7pplmrxT personal_prj_...Created tags: tag_... tag_... tag_...Created 4 tasksTagged tasksYour data is now saved in data/seedorm.json. You can open the file to see it. It's plain JSON, completely human-readable.
Step 7: Query your data
Now the fun part. This is where you realize ORMs don't have to be painful. Let's query the data we just created. Create a new file so we don't re-seed every time:
Find all tasks
1import { SeedORM, FieldType, RelationType } from "seedorm";23async function main() {4 const db = new SeedORM();5 await db.connect();67 // Re-register models (same definitions as before)8 // ... (Project, Task, Tag model definitions) ...910 // Get all tasks11 const allTasks = await Task.find();12 console.log(`Total tasks: ${allTasks.length}`);1314 for (const task of allTasks) {15 const status = task.completed ? "✓" : "○";16 console.log(` ${status} [${task.priority}] ${task.title}`);17 }1819 await db.disconnect();20}2122main();$ npx tsx query.tsTotal tasks: 4 ○ [high] Finish quarterly report ○ [high] Fix login page bug ○ [low] Buy groceries ○ [medium] Plan weekend tripFilter and sort
SeedORM supports MongoDB-style query operators for filtering:
// High priority tasks onlyconst highPriority = await Task.find({ filter: { priority: "high" },});console.log("High priority:", highPriority.map(t => t.title));// ["Finish quarterly report", "Fix login page bug"]// Incomplete tasks, sorted by priorityconst pending = await Task.find({ filter: { completed: false }, sort: { priority: -1 }, // -1 = descending});// Tasks with a due date setconst withDueDate = await Task.find({ filter: { dueDate: { $ne: null } }, sort: { dueDate: 1 }, // 1 = ascending (earliest first)});console.log("Tasks with due dates:", withDueDate.map(t => t.title));Pagination
// Get page 1 (first 2 tasks)const page1 = await Task.find({ limit: 2, offset: 0,});// Get page 2 (next 2 tasks)const page2 = await Task.find({ limit: 2, offset: 2,});const total = await Task.count();console.log(`Showing ${page1.length} of ${total} tasks`);Step 8: Relations in action
This is where SeedORM really shines. Use the include option to load related data in a single call, with no manual joins needed.
Eager loading with include
1// Load a project with all its tasks2const project = await Project.findById(work.id, {3 include: ["tasks"],4});56console.log(project.name); // "Work"7console.log(project.tasks.length); // 28for (const task of project.tasks) {9 console.log(` - ${task.title}`);10}11// Output:12// Work13// 214// - Finish quarterly report15// - Fix login page bugLoad parent from child
// Load a task with its parent projectconst task = await Task.findById(task2.id, { include: ["project"],});console.log(task.title); // "Fix login page bug"console.log(task.project.name); // "Work"Many-to-many: tags
// Load a task with its tagsconst taskWithTags = await Task.findById(task2.id, { include: ["tags"],});console.log(taskWithTags.title); // "Fix login page bug"console.log(taskWithTags.tags);// [// { id: "tag_...", name: "urgent", color: "#dc2626" },// { id: "tag_...", name: "bug", color: "#ea580c" }// ]// Load multiple relations at onceconst fullTask = await Task.findById(task2.id, { include: ["project", "tags"],});console.log(fullTask.project.name); // "Work"console.log(fullTask.tags.length); // 2Step 9: Update and complete tasks
Mark a task as complete
const completed = await Task.update(task1.id, { completed: true,});console.log(completed.title, "| done:", completed.completed);// "Finish quarterly report | done: true"Update multiple fields
const updated = await Task.update(task3.id, { title: "Buy groceries and cook dinner", priority: "medium", dueDate: new Date("2025-03-15").toISOString(),});console.log(updated);// { id: "tsk_...", title: "Buy groceries and cook dinner",// priority: "medium", dueDate: "2025-03-15T..." ... }Add and remove tags
// Add the "urgent" tag to task3await Task.associate(task3.id, "tags", urgent.id);// Verifyconst tagged = await Task.findById(task3.id, { include: ["tags"] });console.log(tagged.tags.map(t => t.name)); // ["urgent"]// Remove the tagawait Task.dissociate(task3.id, "tags", urgent.id);Step 10: Delete tasks
// Delete a single taskawait Task.delete(task3.id);// Delete all completed tasksconst deletedCount = await Task.deleteMany({ completed: true });console.log(`Deleted ${deletedCount} completed task(s)`);// Verifyconst remaining = await Task.find();console.log(`Remaining tasks: ${remaining.length}`);Step 11: Use the REST API
SeedORM includes a built-in dev server that exposes your data as a REST API, perfect for prototyping a frontend or testing with curl.
$ npx seedorm start✓ Connected to databaseseedorm dev serverinfo Listening on http://localhost:4100REST API: GET/POST/PUT/DELETE /api/:collection/:idIn another terminal, try these requests:
# List all tasks$ curl http://localhost:4100/api/tasks | jq# Get high priority tasks$ curl 'http://localhost:4100/api/tasks?filter={"priority":"high"}' | jq# Create a new task$ curl -X POST http://localhost:4100/api/tasks \ -H 'Content-Type: application/json' \ -d '{ "title": "Write documentation", "priority": "medium", "projectId": "prj_YOUR_ID_HERE" }'# Mark a task as done$ curl -X PATCH http://localhost:4100/api/tasks/tsk_YOUR_ID_HERE \ -H 'Content-Type: application/json' \ -d '{"completed": true}'# Delete a task$ curl -X DELETE http://localhost:4100/api/tasks/tsk_YOUR_ID_HEREStep 12: Browse with Studio
Want a visual interface? Launch SeedORM Studio to browse, edit, and delete documents right in your browser.
$ npx seedorm studio✓ Connected to databaseseedorm studioinfo Open http://localhost:4200 in your browserStudio gives you a table view of every collection, click-to-edit on any document, and a form for creating new ones. It's great for inspecting your data while developing.
Step 13: Export to PostgreSQL
When you're ready to move beyond JSON, SeedORM can export your data and schema to SQL. No code changes needed, just generate the SQL and run it.
# Export everything to a SQL file$ npx seedorm migrate to postgres --output todo-app.sql# Or export just one collection$ npx seedorm migrate to postgres --collection tasks --output tasks.sqlThe generated SQL includes table creation and all your data:
-- Export of "tasks" from seedorm-- Generated at 2025-03-01T12:00:00.000ZCREATE TABLE IF NOT EXISTS "tasks" ( "id" TEXT PRIMARY KEY, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "title" TEXT NOT NULL, "completed" BOOLEAN DEFAULT false, "priority" TEXT, "dueDate" TIMESTAMPTZ, "projectId" TEXT NOT NULL, "notes" TEXT DEFAULT '');INSERT INTO "tasks" ("id", "title", "completed", "priority", ...) VALUES ('tsk_7pplmrxT', 'Fix login page bug', false, 'high', ...);To switch your app to PostgreSQL, update your config and install the driver:
$ npm install pg{ "adapter": { "adapter": "postgres", "url": "postgres://user:pass@localhost:5432/todoapp" }, "migrationsDir": "./migrations"}Your application code stays exactly the same: SeedORM, db.model(), Task.find(), all of it. That's the whole point: start with JSON, graduate to SQL.
Full source code
Here's the complete app.ts from this tutorial in one block:
1import { SeedORM, FieldType, RelationType } from "seedorm";23async function main() {4 // ── Connect ──────────────────────────────5 const db = new SeedORM();6 await db.connect();78 // ── Models ───────────────────────────────9 const Project = db.model({10 name: "Project",11 collection: "projects",12 schema: {13 name: { type: FieldType.String, required: true, minLength: 1 },14 description: { type: FieldType.String, default: "" },15 color: { type: FieldType.String, default: "#6366f1" },16 },17 relations: {18 tasks: { type: RelationType.HasMany, model: "Task", foreignKey: "projectId" },19 },20 prefix: "prj",21 });2223 const Task = db.model({24 name: "Task",25 collection: "tasks",26 schema: {27 title: { type: FieldType.String, required: true, minLength: 1 },28 completed: { type: FieldType.Boolean, default: false },29 priority: { type: FieldType.String, enum: ["low", "medium", "high"], default: "medium" },30 dueDate: { type: FieldType.Date },31 projectId: { type: FieldType.String, required: true },32 notes: { type: FieldType.String, default: "" },33 },34 relations: {35 project: { type: RelationType.BelongsTo, model: "Project", foreignKey: "projectId" },36 tags: { type: RelationType.ManyToMany, model: "Tag", joinCollection: "task_tags", foreignKey: "taskId", relatedKey: "tagId" },37 },38 prefix: "tsk",39 });4041 const Tag = db.model({42 name: "Tag",43 collection: "tags",44 schema: {45 name: { type: FieldType.String, required: true, unique: true },46 color: { type: FieldType.String, default: "#94a3b8" },47 },48 relations: {49 tasks: { type: RelationType.ManyToMany, model: "Task", joinCollection: "task_tags", foreignKey: "tagId", relatedKey: "taskId" },50 },51 prefix: "tag",52 });5354 await Project.init();55 await Task.init();56 await Tag.init();5758 // ── Seed data ────────────────────────────59 const work = await Project.create({ name: "Work", description: "Office tasks", color: "#ef4444" });60 const personal = await Project.create({ name: "Personal", description: "Life stuff", color: "#22c55e" });6162 const urgent = await Tag.create({ name: "urgent", color: "#dc2626" });63 const feature = await Tag.create({ name: "feature", color: "#2563eb" });64 const bug = await Tag.create({ name: "bug", color: "#ea580c" });6566 const task1 = await Task.create({ title: "Finish quarterly report", priority: "high", dueDate: new Date("2025-04-01").toISOString(), projectId: work.id });67 const task2 = await Task.create({ title: "Fix login page bug", priority: "high", projectId: work.id, notes: "Users can't log in on Safari" });68 const task3 = await Task.create({ title: "Buy groceries", priority: "low", projectId: personal.id });69 const task4 = await Task.create({ title: "Plan weekend trip", priority: "medium", dueDate: new Date("2025-03-20").toISOString(), projectId: personal.id });7071 await Task.associate(task1.id, "tags", urgent.id);72 await Task.associate(task2.id, "tags", urgent.id);73 await Task.associate(task2.id, "tags", bug.id);74 await Task.associate(task4.id, "tags", feature.id);7576 // ── Query ────────────────────────────────77 const workProject = await Project.findById(work.id, { include: ["tasks"] });78 console.log(workProject.name, "has", workProject.tasks.length, "tasks");7980 const taskWithTags = await Task.findById(task2.id, { include: ["project", "tags"] });81 console.log(taskWithTags.title, "|", taskWithTags.tags.map(t => t.name).join(", "));8283 const highPriority = await Task.find({ filter: { priority: "high" }, sort: { title: 1 } });84 console.log("High priority:", highPriority.map(t => t.title));8586 // ── Update ───────────────────────────────87 await Task.update(task1.id, { completed: true });8889 // ── Clean up ─────────────────────────────90 await db.disconnect();91 console.log("Done!");92}9394main();Next steps
- API Reference: every method, option, and query operator
- Relations: deep dive into hasOne, hasMany, belongsTo, and manyToMany
- CLI Reference: all commands and flags
- Switching Adapters: move from JSON to PostgreSQL or MySQL