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/ directory

We'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.

app.tstypescript
1import { SeedORM, FieldType, RelationType } from "seedorm";
2
3async function main() {
4 const db = new SeedORM();
5 await db.connect();
6
7 console.log("Connected to SeedORM!");
8
9 // ... we'll add models and logic here ...
10
11 await db.disconnect();
12}
13
14main();

Run it to make sure everything is wired up:

$ npx tsx app.ts
Connected 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():

app.tstypescript
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});
18
19await Project.init();

A few things to notice:

  • FieldType.String keeps field types type-safe with no typos.
  • The relations object declares that a Project has many Tasks, linked by projectId on the Task side.
  • The prefix gives generated IDs a human-readable prefix like prj_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.

app.tstypescript
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});
28
29await Task.init();

The Task model introduces two new relation types:

  • belongsTo is the inverse of the Project's hasMany. The foreign key (projectId) lives on this model.
  • manyToMany means a task can have many tags, and a tag can belong to many tasks. SeedORM uses a task_tags join 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.

app.tstypescript
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});
19
20await 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.

app.tstypescript
1// Create a project
2const work = await Project.create({
3 name: "Work",
4 description: "Office tasks and deadlines",
5 color: "#ef4444",
6});
7
8const personal = await Project.create({
9 name: "Personal",
10 description: "Life stuff",
11 color: "#22c55e",
12});
13
14console.log("Created projects:", work.id, personal.id);
15
16// Create tags
17const 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" });
20
21console.log("Created tags:", urgent.id, feature.id, bug.id);
22
23// Create tasks
24const 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});
30
31const 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});
37
38const task3 = await Task.create({
39 title: "Buy groceries",
40 priority: "low",
41 projectId: personal.id,
42});
43
44const 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});
50
51console.log("Created 4 tasks");
52
53// Tag the tasks
54await 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);
58
59console.log("Tagged tasks");

Run it:

$ npx tsx app.ts
Connected to SeedORM!
Created projects: prj_7pplmrxT personal_prj_...
Created tags: tag_... tag_... tag_...
Created 4 tasks
Tagged tasks

Your 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

query.tstypescript
1import { SeedORM, FieldType, RelationType } from "seedorm";
2
3async function main() {
4 const db = new SeedORM();
5 await db.connect();
6
7 // Re-register models (same definitions as before)
8 // ... (Project, Task, Tag model definitions) ...
9
10 // Get all tasks
11 const allTasks = await Task.find();
12 console.log(`Total tasks: ${allTasks.length}`);
13
14 for (const task of allTasks) {
15 const status = task.completed ? "✓" : "○";
16 console.log(` ${status} [${task.priority}] ${task.title}`);
17 }
18
19 await db.disconnect();
20}
21
22main();
$ npx tsx query.ts
Total tasks: 4
○ [high] Finish quarterly report
○ [high] Fix login page bug
○ [low] Buy groceries
○ [medium] Plan weekend trip

Filter and sort

SeedORM supports MongoDB-style query operators for filtering:

query.tstypescript
// High priority tasks only
const 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 priority
const pending = await Task.find({
filter: { completed: false },
sort: { priority: -1 }, // -1 = descending
});
// Tasks with a due date set
const 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

query.tstypescript
// 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

query.tstypescript
1// Load a project with all its tasks
2const project = await Project.findById(work.id, {
3 include: ["tasks"],
4});
5
6console.log(project.name); // "Work"
7console.log(project.tasks.length); // 2
8for (const task of project.tasks) {
9 console.log(` - ${task.title}`);
10}
11// Output:
12// Work
13// 2
14// - Finish quarterly report
15// - Fix login page bug

Load parent from child

query.tstypescript
// Load a task with its parent project
const 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

query.tstypescript
// Load a task with its tags
const 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 once
const fullTask = await Task.findById(task2.id, {
include: ["project", "tags"],
});
console.log(fullTask.project.name); // "Work"
console.log(fullTask.tags.length); // 2

Step 9: Update and complete tasks

Mark a task as complete

query.tstypescript
const completed = await Task.update(task1.id, {
completed: true,
});
console.log(completed.title, "| done:", completed.completed);
// "Finish quarterly report | done: true"

Update multiple fields

query.tstypescript
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

query.tstypescript
// Add the "urgent" tag to task3
await Task.associate(task3.id, "tags", urgent.id);
// Verify
const tagged = await Task.findById(task3.id, { include: ["tags"] });
console.log(tagged.tags.map(t => t.name)); // ["urgent"]
// Remove the tag
await Task.dissociate(task3.id, "tags", urgent.id);

Step 10: Delete tasks

query.tstypescript
// Delete a single task
await Task.delete(task3.id);
// Delete all completed tasks
const deletedCount = await Task.deleteMany({ completed: true });
console.log(`Deleted ${deletedCount} completed task(s)`);
// Verify
const 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 database
seedorm dev server
info Listening on http://localhost:4100
REST API: GET/POST/PUT/DELETE /api/:collection/:id

In 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_HERE

Step 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 database
seedorm studio
info Open http://localhost:4200 in your browser

Studio 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.sql

The generated SQL includes table creation and all your data:

todo-app.sqlsql
-- Export of "tasks" from seedorm
-- Generated at 2025-03-01T12:00:00.000Z
CREATE 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
seedorm.config.jsonjson
{
"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:

app.tstypescript
1import { SeedORM, FieldType, RelationType } from "seedorm";
2
3async function main() {
4 // ── Connect ──────────────────────────────
5 const db = new SeedORM();
6 await db.connect();
7
8 // ── 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 });
22
23 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 });
40
41 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 });
53
54 await Project.init();
55 await Task.init();
56 await Tag.init();
57
58 // ── 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" });
61
62 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" });
65
66 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 });
70
71 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);
75
76 // ── Query ────────────────────────────────
77 const workProject = await Project.findById(work.id, { include: ["tasks"] });
78 console.log(workProject.name, "has", workProject.tasks.length, "tasks");
79
80 const taskWithTags = await Task.findById(task2.id, { include: ["project", "tags"] });
81 console.log(taskWithTags.title, "|", taskWithTags.tags.map(t => t.name).join(", "));
82
83 const highPriority = await Task.find({ filter: { priority: "high" }, sort: { title: 1 } });
84 console.log("High priority:", highPriority.map(t => t.title));
85
86 // ── Update ───────────────────────────────
87 await Task.update(task1.id, { completed: true });
88
89 // ── Clean up ─────────────────────────────
90 await db.disconnect();
91 console.log("Done!");
92}
93
94main();

Next steps