Skip to content

Data Table Tutorial

In this tutorial you will build a task manager database with three tables: users, projects, and tasks. You will learn how to define tables, set up relations, perform CRUD operations, filter with operators, use transactions, and run migrations.

Prerequisites

Install Remix and the SQLite adapter (we use SQLite for simplicity --- the API is identical for PostgreSQL and MySQL):

bash
npm install remix better-sqlite3
npm install -D @types/better-sqlite3

Step 1 --- Define the Tables

Create a file for your table definitions. Each table uses the table function and the column builder (aliased as c):

ts
// db/tables.ts
import { table, column as c } from 'remix/data-table'

export let users = table({
  name: 'users',
  columns: {
    id: c.integer().primaryKey().autoIncrement(),
    email: c.varchar(255).notNull().unique('users_email_uq'),
    name: c.text().notNull(),
    role: c.enum(['admin', 'member']).notNull().default('member'),
    createdAt: c.timestamp().notNull().defaultNow(),
    updatedAt: c.timestamp().notNull().defaultNow(),
  },
  timestamps: true,
})

export let projects = table({
  name: 'projects',
  columns: {
    id: c.integer().primaryKey().autoIncrement(),
    name: c.text().notNull(),
    description: c.text().nullable(),
    ownerId: c.integer().notNull().references('users', 'fk_projects_owner'),
    createdAt: c.timestamp().notNull().defaultNow(),
    updatedAt: c.timestamp().notNull().defaultNow(),
  },
  timestamps: true,
})

export let tasks = table({
  name: 'tasks',
  columns: {
    id: c.integer().primaryKey().autoIncrement(),
    title: c.text().notNull(),
    description: c.text().nullable(),
    status: c.enum(['todo', 'in_progress', 'done']).notNull().default('todo'),
    priority: c.integer().notNull().default(0),
    projectId: c.integer().notNull().references('projects', 'fk_tasks_project'),
    assigneeId: c.integer().nullable().references('users', 'fk_tasks_assignee'),
    createdAt: c.timestamp().notNull().defaultNow(),
    updatedAt: c.timestamp().notNull().defaultNow(),
  },
  timestamps: true,
})

What does .references() do?

It declares a foreign key constraint. The first argument is the target table name, and the second is a constraint name. This tells the database to enforce referential integrity --- you cannot insert a projectId that does not exist in the projects table.

Step 2 --- Set Up the Database

Create a database instance using the SQLite adapter:

ts
// db/index.ts
import SQLite from 'better-sqlite3'
import { createSqliteDatabaseAdapter } from 'remix/data-table-sqlite'
import { createDatabase } from 'remix/data-table'

let sqlite = new SQLite('taskmanager.db')
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')

let adapter = createSqliteDatabaseAdapter(sqlite)
export let db = createDatabase(adapter)

What is WAL mode?

WAL (Write-Ahead Logging) is a SQLite journal mode that allows concurrent reads while a write is in progress. It improves performance for web applications where multiple requests may read at the same time.

Step 3 --- Define Relations

Relations tell data-table how tables connect. Define them alongside your tables:

ts
// db/relations.ts
import { hasMany, belongsTo } from 'remix/data-table'
import { users, projects, tasks } from './tables'

// A user owns many projects
export let userProjects = hasMany(users, projects, { foreignKey: 'ownerId' })

// A project belongs to a user (its owner)
export let projectOwner = belongsTo(projects, users, { foreignKey: 'ownerId' })

// A project has many tasks
export let projectTasks = hasMany(projects, tasks, { foreignKey: 'projectId' })

// A task belongs to a project
export let taskProject = belongsTo(tasks, projects, { foreignKey: 'projectId' })

// A task optionally belongs to an assignee
export let taskAssignee = belongsTo(tasks, users, { foreignKey: 'assigneeId' })

// A user has many assigned tasks
export let userTasks = hasMany(users, tasks, { foreignKey: 'assigneeId' })

Step 4 --- Create Data (INSERT)

Use db.create() to insert a single row and db.createMany() for bulk inserts:

ts
import { db } from './db'
import { users, projects, tasks } from './db/tables'

// Create a user and get the full row back
let alice = await db.create(users, {
  email: 'alice@example.com',
  name: 'Alice',
  role: 'admin',
}, { returnRow: true })

let bob = await db.create(users, {
  email: 'bob@example.com',
  name: 'Bob',
}, { returnRow: true })

// Create a project
let project = await db.create(projects, {
  name: 'Website Redesign',
  description: 'Rebuild the marketing site with Remix V3.',
  ownerId: alice.id,
}, { returnRow: true })

// Create several tasks at once
await db.createMany(tasks, [
  { title: 'Design mockups', projectId: project.id, assigneeId: alice.id, priority: 3 },
  { title: 'Set up routing', projectId: project.id, assigneeId: bob.id, priority: 2 },
  { title: 'Write copy', projectId: project.id, priority: 1 },
  { title: 'Deploy to staging', projectId: project.id, status: 'todo', priority: 0 },
])

returnRow: true

By default, db.create() returns a WriteResult with affectedRows and insertId. Pass { returnRow: true } to get the full inserted row with all default values resolved.

Step 5 --- Read Data (SELECT)

Find by primary key

ts
let user = await db.find(users, 1)
// { id: 1, email: 'alice@example.com', name: 'Alice', ... }

Find one by condition

ts
let admin = await db.findOne(users, {
  where: { role: 'admin' },
})

Find many with filtering and sorting

ts
let openTasks = await db.findMany(tasks, {
  where: { status: 'todo' },
  orderBy: [['priority', 'desc']],
  limit: 10,
})

Count rows

ts
let totalTasks = await db.count(tasks)
let doneTasks = await db.count(tasks, { where: { status: 'done' } })

Step 6 --- Filter with Operators

For complex conditions, use operators from remix/data-table/operators:

ts
import { eq, gt, like, inList, and, or } from 'remix/data-table/operators'

// High-priority incomplete tasks
let urgent = await db.findMany(tasks, {
  where: and(
    gt('priority', 2),
    inList('status', ['todo', 'in_progress']),
  ),
  orderBy: [['priority', 'desc']],
})

// Tasks matching a search term
let results = await db.findMany(tasks, {
  where: like('title', '%routing%'),
})

// Tasks that are done OR unassigned
let filtered = await db.findMany(tasks, {
  where: or(
    eq('status', 'done'),
    eq('assigneeId', null),
  ),
})

Operator reference

The full list of operators is: eq, ne, gt, gte, lt, lte, like, ilike, inList, notInList, between, isNull, notNull, and, or. See the reference for details.

Step 7 --- Eager Loading with Relations

Load related data in a single query using the with option:

ts
import { userProjects, projectTasks, projectOwner, taskAssignee } from './db/relations'

// Load a user with all their projects
let userWithProjects = await db.find(users, 1, {
  with: { projects: userProjects },
})
// userWithProjects.projects is Project[]

// Load a project with its owner and tasks (including each task's assignee)
let fullProject = await db.find(projects, 1, {
  with: {
    owner: projectOwner,
    tasks: projectTasks.with({ assignee: taskAssignee }),
  },
})
// fullProject.owner is a User
// fullProject.tasks[0].assignee is a User | null

Filtering and limiting relations

ts
// Only the 5 most recent tasks for a project
let recentTasks = projectTasks
  .where({ status: 'todo' })
  .orderBy('createdAt', 'desc')
  .limit(5)

let project = await db.find(projects, 1, {
  with: { recentTasks },
})

Step 8 --- Update Data

Update a single row by primary key

ts
let updated = await db.update(tasks, 1, {
  status: 'in_progress',
})
// Returns the updated row

Update many rows by condition

ts
let result = await db.updateMany(tasks, { status: 'done' }, {
  where: { projectId: 1, status: 'in_progress' },
})
// result.affectedRows = number of rows updated

Step 9 --- Delete Data

Delete a single row

ts
let deleted = await db.delete(tasks, 4)  // true or false

Delete many rows

ts
let result = await db.deleteMany(tasks, {
  where: { status: 'done' },
})

Step 10 --- Use the Query Builder

The query builder provides a fluent interface for more complex queries:

ts
// Find all tasks assigned to Alice in the "Website Redesign" project
let aliceTasks = await db.query(tasks)
  .join(projects, { 'tasks.projectId': 'projects.id' })
  .where(and(
    eq('tasks.assigneeId', alice.id),
    eq('projects.name', 'Website Redesign'),
  ))
  .select(['tasks.id', 'tasks.title', 'tasks.status'])
  .orderBy('tasks.priority', 'desc')
  .all()

// Check if any task exists
let hasTasks = await db.query(tasks)
  .where({ projectId: 1 })
  .exists()

Step 11 --- Transactions

Wrap multiple operations in a transaction. If any operation throws, everything rolls back:

ts
await db.transaction(async (tx) => {
  // Create a project and its first task atomically
  let project = await tx.create(projects, {
    name: 'New Feature',
    ownerId: alice.id,
  }, { returnRow: true })

  await tx.create(tasks, {
    title: 'Plan feature scope',
    projectId: project.id,
    assigneeId: alice.id,
    priority: 3,
  })

  await tx.create(tasks, {
    title: 'Write tests',
    projectId: project.id,
    priority: 2,
  })
})

Nested transactions

data-table supports nested transactions via savepoints. If an inner tx.transaction() throws, only the inner savepoint rolls back.

Step 12 --- Migrations

Migrations create and modify database tables over time. Define them with createMigration:

ts
// migrations/001-initial.ts
import { createMigration, column as c } from 'remix/data-table/migrations'

export default createMigration({
  up(ctx) {
    ctx.createTable('users', {
      id: c.integer().primaryKey().autoIncrement(),
      email: c.varchar(255).notNull().unique('users_email_uq'),
      name: c.text().notNull(),
      role: c.enum(['admin', 'member']).notNull().default('member'),
      createdAt: c.timestamp().notNull().defaultNow(),
      updatedAt: c.timestamp().notNull().defaultNow(),
    })

    ctx.createTable('projects', {
      id: c.integer().primaryKey().autoIncrement(),
      name: c.text().notNull(),
      description: c.text().nullable(),
      ownerId: c.integer().notNull().references('users', 'fk_projects_owner'),
      createdAt: c.timestamp().notNull().defaultNow(),
      updatedAt: c.timestamp().notNull().defaultNow(),
    })

    ctx.createTable('tasks', {
      id: c.integer().primaryKey().autoIncrement(),
      title: c.text().notNull(),
      description: c.text().nullable(),
      status: c.enum(['todo', 'in_progress', 'done']).notNull().default('todo'),
      priority: c.integer().notNull().default(0),
      projectId: c.integer().notNull().references('projects', 'fk_tasks_project'),
      assigneeId: c.integer().nullable().references('users', 'fk_tasks_assignee'),
      createdAt: c.timestamp().notNull().defaultNow(),
      updatedAt: c.timestamp().notNull().defaultNow(),
    })

    ctx.createIndex('tasks', ['projectId'], { name: 'idx_tasks_project' })
    ctx.createIndex('tasks', ['assigneeId'], { name: 'idx_tasks_assignee' })
    ctx.createIndex('tasks', ['status'], { name: 'idx_tasks_status' })
  },

  down(ctx) {
    ctx.dropTable('tasks')
    ctx.dropTable('projects')
    ctx.dropTable('users')
  },
})

Running migrations

ts
import { createMigrationRunner } from 'remix/data-table/migrations'
import { loadMigrations } from 'remix/data-table/migrations/node'

let migrations = await loadMigrations('./migrations')
let runner = createMigrationRunner(adapter)

// Apply all pending migrations
await runner.up(migrations)

// Roll back the last migration
await runner.down(migrations)

What You Learned

  • Table definitions: Use table() and column builders to declare your schema with full TypeScript inference.
  • Relations: hasMany, belongsTo, and the .with() option for eager loading.
  • CRUD: db.create(), db.find(), db.findMany(), db.update(), db.delete().
  • Operators: eq, gt, like, inList, and, or for complex WHERE clauses.
  • Query builder: Fluent API with .join(), .select(), .orderBy(), .limit().
  • Transactions: db.transaction() with automatic rollback and nested savepoints.
  • Migrations: createMigration with up/down for schema changes, loadMigrations for loading from disk.

Released under the MIT License.