Skip to content

SaaS Tutorial: Database ​

With auth in place, we need a clean data access layer. In this chapter, you'll build a query module on top of Drizzle ORM that the rest of the app — loaders, actions, and RPC procedures — will use. You'll also build the project list and project detail routes.

Query Module ​

Create a module that encapsulates all database queries. This keeps SQL out of route files and makes queries reusable across loaders, actions, and RPC procedures.

ts
// app/lib/queries.ts
import { db } from './db'
import { projects, tasks, teamMembers, users, activity } from './schema'
import { eq, and, desc, count, sql } from 'drizzle-orm'

// --- Projects ---

export function getProjectsForTeam(teamId: string) {
  return db
    .select({
      id: projects.id,
      name: projects.name,
      description: projects.description,
      color: projects.color,
      createdAt: projects.createdAt,
      taskCount: count(tasks.id),
    })
    .from(projects)
    .leftJoin(tasks, eq(tasks.projectId, projects.id))
    .where(eq(projects.teamId, teamId))
    .groupBy(projects.id)
    .orderBy(desc(projects.createdAt))
    .all()
}

export function getProject(projectId: string, teamId: string) {
  return db
    .select()
    .from(projects)
    .where(and(eq(projects.id, projectId), eq(projects.teamId, teamId)))
    .get()
}

export function createProject(data: { teamId: string; name: string; description?: string; color?: string }) {
  const id = `proj_${crypto.randomUUID()}`
  db.insert(projects).values({ id, ...data }).run()
  return id
}

// --- Tasks ---

export function getTasksForProject(projectId: string) {
  return db
    .select({
      id: tasks.id,
      title: tasks.title,
      description: tasks.description,
      status: tasks.status,
      priority: tasks.priority,
      dueDate: tasks.dueDate,
      createdAt: tasks.createdAt,
      assigneeName: users.name,
      assigneeId: tasks.assigneeId,
    })
    .from(tasks)
    .leftJoin(users, eq(users.id, tasks.assigneeId))
    .where(eq(tasks.projectId, projectId))
    .orderBy(desc(tasks.createdAt))
    .all()
}

export function getTask(taskId: string) {
  return db.select().from(tasks).where(eq(tasks.id, taskId)).get()
}

export function createTask(data: {
  projectId: string
  title: string
  description?: string
  status?: 'todo' | 'in_progress' | 'done'
  priority?: 'low' | 'medium' | 'high' | 'urgent'
  assigneeId?: string
  dueDate?: Date
  createdById: string
}) {
  const id = `task_${crypto.randomUUID()}`
  db.insert(tasks).values({ id, ...data }).run()
  return id
}

export function updateTask(taskId: string, data: Partial<{
  title: string
  description: string
  status: 'todo' | 'in_progress' | 'done'
  priority: 'low' | 'medium' | 'high' | 'urgent'
  assigneeId: string | null
  dueDate: Date | null
}>) {
  db.update(tasks)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(tasks.id, taskId))
    .run()
}

export function deleteTask(taskId: string) {
  db.delete(tasks).where(eq(tasks.id, taskId)).run()
}

// --- Team Members ---

export function getTeamMembers(teamId: string) {
  return db
    .select({
      id: teamMembers.id,
      userId: users.id,
      name: users.name,
      email: users.email,
      role: teamMembers.role,
      avatarUrl: users.avatarUrl,
    })
    .from(teamMembers)
    .innerJoin(users, eq(users.id, teamMembers.userId))
    .where(eq(teamMembers.teamId, teamId))
    .all()
}

// --- Dashboard Stats ---

export function getDashboardStats(teamId: string) {
  const projectCount = db
    .select({ value: count() })
    .from(projects)
    .where(eq(projects.teamId, teamId))
    .get()

  const taskStats = db
    .select({
      status: tasks.status,
      count: count(),
    })
    .from(tasks)
    .innerJoin(projects, eq(projects.id, tasks.projectId))
    .where(eq(projects.teamId, teamId))
    .groupBy(tasks.status)
    .all()

  const memberCount = db
    .select({ value: count() })
    .from(teamMembers)
    .where(eq(teamMembers.teamId, teamId))
    .get()

  const statusMap = Object.fromEntries(taskStats.map((s) => [s.status, s.count]))

  return {
    projects: projectCount?.value ?? 0,
    members: memberCount?.value ?? 0,
    tasks: {
      todo: statusMap.todo ?? 0,
      in_progress: statusMap.in_progress ?? 0,
      done: statusMap.done ?? 0,
      total: taskStats.reduce((sum, s) => sum + s.count, 0),
    },
  }
}

// --- Activity Log ---

export function logActivity(data: {
  teamId: string
  userId: string
  action: string
  targetType: string
  targetId: string
  metadata?: Record<string, unknown>
}) {
  const id = `act_${crypto.randomUUID()}`
  db.insert(activity).values({ id, ...data }).run()
  return id
}

export function getRecentActivity(teamId: string, limit = 20) {
  return db
    .select({
      id: activity.id,
      action: activity.action,
      targetType: activity.targetType,
      targetId: activity.targetId,
      metadata: activity.metadata,
      createdAt: activity.createdAt,
      userName: users.name,
    })
    .from(activity)
    .innerJoin(users, eq(users.id, activity.userId))
    .where(eq(activity.teamId, teamId))
    .orderBy(desc(activity.createdAt))
    .limit(limit)
    .all()
}

Project List Route ​

Now use these queries in a route with a loader:

tsx
// app/routes/dashboard/projects/index.tsx
import { createLoader } from '@ereo/data'
import { Link, useLoaderData } from '@ereo/client'
import { getProjectsForTeam } from '~/lib/queries'
import { getUser } from '@ereo/auth'
import { db } from '~/lib/db'
import { teamMembers } from '~/lib/schema'
import { eq } from 'drizzle-orm'
import type { RouteComponentProps } from '@ereo/core'

export const loader = createLoader(async ({ context }) => {
  const user = getUser(context)
  const membership = db.select().from(teamMembers).where(eq(teamMembers.userId, user!.id)).get()
  const projects = getProjectsForTeam(membership!.teamId)
  return { projects }
})

export default function ProjectList({ loaderData }: RouteComponentProps) {
  const { projects } = loaderData

  return (
    <div className="p-8">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">Projects</h1>
        <Link
          href="/dashboard/projects/new"
          className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
        >
          New Project
        </Link>
      </div>

      {projects.length === 0 ? (
        <div className="text-center py-12">
          <p className="text-gray-500 mb-4">No projects yet. Create your first one.</p>
          <Link href="/dashboard/projects/new" className="text-blue-600 hover:underline">
            Create a project
          </Link>
        </div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {projects.map((project) => (
            <Link
              key={project.id}
              href={`/dashboard/projects/${project.id}`}
              className="block bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow"
            >
              <div className="flex items-center gap-3 mb-3">
                <div
                  className="w-3 h-3 rounded-full"
                  style={{ backgroundColor: project.color }}
                />
                <h3 className="font-semibold">{project.name}</h3>
              </div>
              {project.description && (
                <p className="text-sm text-gray-600 mb-3 line-clamp-2">{project.description}</p>
              )}
              <p className="text-xs text-gray-400">{project.taskCount} tasks</p>
            </Link>
          ))}
        </div>
      )}
    </div>
  )
}

Create Project Route ​

A simple form for creating new projects:

tsx
// app/routes/dashboard/projects/new.tsx
import { createAction, redirect } from '@ereo/data'
import { Form, Link, useActionData, useNavigation } from '@ereo/client'
import { createProject, logActivity } from '~/lib/queries'
import { getUser } from '@ereo/auth'
import { db } from '~/lib/db'
import { teamMembers } from '~/lib/schema'
import { eq } from 'drizzle-orm'
import type { RouteComponentProps } from '@ereo/core'

const COLORS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4']

export const action = createAction(async ({ request, context }) => {
  const formData = await request.formData()
  const name = formData.get('name') as string
  const description = formData.get('description') as string
  const color = formData.get('color') as string || '#3b82f6'

  if (!name || name.length < 2) {
    return { success: false, errors: { name: 'Project name must be at least 2 characters.' } }
  }

  const user = getUser(context)!
  const membership = db.select().from(teamMembers).where(eq(teamMembers.userId, user.id)).get()!

  const projectId = createProject({
    teamId: membership.teamId,
    name,
    description: description || undefined,
    color,
  })

  logActivity({
    teamId: membership.teamId,
    userId: user.id,
    action: 'created',
    targetType: 'project',
    targetId: projectId,
    metadata: { name },
  })

  return redirect(`/dashboard/projects/${projectId}`)
})

export default function NewProject(props: RouteComponentProps) {
  const actionData = useActionData<{ success: boolean; errors?: Record<string, string> }>()
  const navigation = useNavigation()
  const isSubmitting = navigation.state === 'submitting'

  return (
    <div className="p-8 max-w-xl">
      <h1 className="text-2xl font-bold mb-6">New Project</h1>

      <Form method="post" className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
            Project name
          </label>
          <input
            id="name"
            name="name"
            type="text"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
            placeholder="e.g. Website Redesign"
          />
          {actionData?.errors?.name && (
            <p className="text-sm text-red-600 mt-1">{actionData.errors.name}</p>
          )}
        </div>

        <div>
          <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
            Description (optional)
          </label>
          <textarea
            id="description"
            name="description"
            rows={3}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          />
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-2">Color</label>
          <div className="flex gap-2">
            {COLORS.map((color) => (
              <label key={color} className="cursor-pointer">
                <input type="radio" name="color" value={color} defaultChecked={color === '#3b82f6'} className="sr-only peer" />
                <div
                  className="w-8 h-8 rounded-full border-2 border-transparent peer-checked:border-gray-900 peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900"
                  style={{ backgroundColor: color }}
                />
              </label>
            ))}
          </div>
        </div>

        <div className="flex gap-3 pt-2">
          <button
            type="submit"
            disabled={isSubmitting}
            className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
          >
            {isSubmitting ? 'Creating...' : 'Create Project'}
          </button>
          <Link href="/dashboard/projects" className="px-4 py-2 text-gray-600 text-sm hover:text-gray-900">
            Cancel
          </Link>
        </div>
      </Form>
    </div>
  )
}

Project Detail Route ​

This route loads a project and its tasks. The task list is displayed grouped by status (todo, in progress, done):

tsx
// app/routes/dashboard/projects/[id].tsx
import { createLoader } from '@ereo/data'
import { Link, useLoaderData } from '@ereo/client'
import { getProject, getTasksForProject, getTeamMembers } from '~/lib/queries'
import { getUser } from '@ereo/auth'
import { db } from '~/lib/db'
import { teamMembers } from '~/lib/schema'
import { eq } from 'drizzle-orm'
import type { RouteComponentProps } from '@ereo/core'

export const loader = createLoader(async ({ params, context }) => {
  const user = getUser(context)!
  const membership = db.select().from(teamMembers).where(eq(teamMembers.userId, user.id)).get()!

  const project = getProject(params.id, membership.teamId)
  if (!project) throw new Response('Project not found', { status: 404 })

  const tasks = getTasksForProject(project.id)
  const members = getTeamMembers(membership.teamId)

  return { project, tasks, members, teamId: membership.teamId }
})

const STATUS_LABELS: Record<string, string> = {
  todo: 'To Do',
  in_progress: 'In Progress',
  done: 'Done',
}

const PRIORITY_COLORS: Record<string, string> = {
  low: 'bg-gray-100 text-gray-700',
  medium: 'bg-blue-100 text-blue-700',
  high: 'bg-orange-100 text-orange-700',
  urgent: 'bg-red-100 text-red-700',
}

export default function ProjectDetail({ loaderData }: RouteComponentProps) {
  const { project, tasks, members } = loaderData

  const grouped = {
    todo: tasks.filter((t: any) => t.status === 'todo'),
    in_progress: tasks.filter((t: any) => t.status === 'in_progress'),
    done: tasks.filter((t: any) => t.status === 'done'),
  }

  return (
    <div className="p-8">
      <div className="flex items-center justify-between mb-6">
        <div className="flex items-center gap-3">
          <div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color }} />
          <h1 className="text-2xl font-bold">{project.name}</h1>
        </div>
        <Link
          href={`/dashboard/projects/${project.id}/tasks/new`}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
        >
          Add Task
        </Link>
      </div>

      {project.description && (
        <p className="text-gray-600 mb-6">{project.description}</p>
      )}

      {/* Task columns */}
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {(['todo', 'in_progress', 'done'] as const).map((status) => (
          <div key={status}>
            <div className="flex items-center gap-2 mb-3">
              <h2 className="font-semibold text-sm text-gray-500 uppercase tracking-wide">
                {STATUS_LABELS[status]}
              </h2>
              <span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
                {grouped[status].length}
              </span>
            </div>
            <div className="space-y-3">
              {grouped[status].map((task: any) => (
                <div key={task.id} className="bg-white rounded-lg border border-gray-200 p-4">
                  <h3 className="font-medium mb-2">{task.title}</h3>
                  <div className="flex items-center gap-2">
                    <span className={`text-xs px-2 py-0.5 rounded-full ${PRIORITY_COLORS[task.priority]}`}>
                      {task.priority}
                    </span>
                    {task.assigneeName && (
                      <span className="text-xs text-gray-500">{task.assigneeName}</span>
                    )}
                    {task.dueDate && (
                      <span className="text-xs text-gray-400">
                        {new Date(task.dueDate).toLocaleDateString()}
                      </span>
                    )}
                  </div>
                </div>
              ))}
              {grouped[status].length === 0 && (
                <p className="text-sm text-gray-400 text-center py-4">No tasks</p>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

Understanding the Data Flow ​

The query module acts as a clean boundary between routes and the database:

Route Loader                     Query Module                    Drizzle ORM
┌──────────┐                    ┌──────────────┐               ┌──────────┐
│ loader() │───getProject()────▶│ queries.ts   │───select()───▶│ SQLite   │
│          │───getTasksFor      │              │───join()       │          │
│          │   Project()────────▶│              │───where()      │          │
│          │◀──returns data──────│              │◀──returns      │          │
└──────────┘                    └──────────────┘               └──────────┘

This separation means:

  • Routes handle request/response logic and call query functions
  • Queries handle SQL composition and return typed results
  • RPC procedures (Chapter 6) will reuse the same query functions

Try It Out ​

  1. Navigate to /dashboard/projects — you should see the seeded "Website Redesign" project
  2. Click "New Project" and create a project
  3. Click into a project to see its tasks grouped by status
  4. The "Add Task" link won't work yet — we'll build that form next

What We've Done ​

  • Built a reusable query module with typed database operations
  • Created project list, create, and detail routes with loaders and actions
  • Displayed tasks grouped by status in a Kanban-style layout
  • Added activity logging for audit trails
  • Established a pattern for clean data access that loaders, actions, and RPC procedures can all share

Next Step ​

The database layer is solid. In the next chapter, we'll build the task creation and editing forms using @ereo/forms — with client-side validation, field arrays, and submit handling.

← Previous: Authentication | Continue to Chapter 4: Forms →

Released under the MIT License.