JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialprismanextjsnpmStorybookcssTailwind

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

AdminMarch 19, 202612 min read
Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

On this page

  • Part 1: Project Setup and Data Modeling
  • Why This Stack?
  • Next.js (App Router)
  • @dxsolo/ui
  • Prisma + PostgreSQL
  • Why Not a Lightweight Framework Like Lit or Stencil?
  • Scaffolding the Project
  • Step 1: Create the Next.js App
  • Step 2: Install Core Dependencies
  • Step 3: Configure Tailwind for @dxsolo/ui
  • Step 4: Docker for Local PostgreSQL
  • Step 5: Initialize Prisma
  • The Data Model
  • What a Solo Dev Kanban Needs
  • Decision: Status as an Enum vs. a Separate Table
  • Decision: Issue Ordering
  • The Prisma Schema
  • Run the Migration
  • Seed Data
  • Project Structure
  • The Prisma Client Singleton
  • A Quick Smoke Test
  • What We Built in Part 1
  • What is Next

On this page

  • Part 1: Project Setup and Data Modeling
  • Why This Stack?
  • Next.js (App Router)
  • @dxsolo/ui
  • Prisma + PostgreSQL
  • Why Not a Lightweight Framework Like Lit or Stencil?
  • Scaffolding the Project
  • Step 1: Create the Next.js App
  • Step 2: Install Core Dependencies
  • Step 3: Configure Tailwind for @dxsolo/ui
  • Step 4: Docker for Local PostgreSQL
  • Step 5: Initialize Prisma
  • The Data Model
  • What a Solo Dev Kanban Needs
  • Decision: Status as an Enum vs. a Separate Table
  • Decision: Issue Ordering
  • The Prisma Schema
  • Run the Migration
  • Seed Data
  • Project Structure
  • The Prisma Client Singleton
  • A Quick Smoke Test
  • What We Built in Part 1
  • What is Next
PreviousBuilding a Component Library: CI/CD and Automated ReleasesNextBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 2

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

Part 1: Project Setup and Data Modeling

This is Part 1 of a 6-part series where we build a complete kanban board for solo developers. By the end, you will have a fully functional project management tool, a live demo, and a forkable GitHub repo. Along the way, we will contribute new components back to the @dxsolo/ui component library.

Series overview:

  1. Project Setup and Data Modeling (you are here)
  2. The Board Layout: CSS Grid, Container Queries, and Column Architecture
  3. Drag and Drop That Actually Works
  4. The Issue Detail View with Markdown Editing
  5. Projects, Epics, and Filtering
  6. Polish, Persistence, and Publishing

What we are building: a single-dev kanban board with projects, epics, issues, and subtasks. Issues live in four columns (Open, InProgress, Review, Done) and can be dragged between them. Each issue has a markdown editor for descriptions and notes. The whole thing runs on Next.js, uses @dxsolo/ui for the component layer, and persists data with Prisma and PostgreSQL.


Why This Stack?

Before we write a single line of code, let's talk about why we are reaching for these specific tools.

Next.js (App Router)

The dxpress project already runs on Next.js 14+ with the App Router, TypeScript, and Tailwind CSS. That matters here because the kanban app will share the same architecture patterns, the same deployment pipeline (AWS Amplify), and the same component library. If you have built anything with the App Router's server components and server actions, you will feel right at home.

For readers who haven't used Next.js before: the App Router gives us file-based routing, React Server Components by default, and built-in API routes. We get server-side rendering where we want it and client-side interactivity where we need it, all in one project.

@dxsolo/ui

This is the component library we will lean on and grow throughout the series. It is a set of accessible React components styled with Tailwind CSS v4, published on npm as @dxsolo/ui. You can browse every component and its variants in the Storybook demo.

Right now, the library ships components like Button, Card, and CardContent. Over the course of this series, we will add new components that the kanban app needs: things like a Sidebar, a FilterBar, and a MarkdownEditor wrapper. The goal is to build the app and the library in tandem, the way real products evolve their design systems.

Prisma + PostgreSQL

We need relational data. Projects contain epics, epics contain issues, issues contain subtasks. Prisma gives us a typed schema, migrations, and a client that plays nicely with TypeScript. PostgreSQL handles the relational heavy lifting. For local development, we will use Docker, same as dxpress.

Why Not a Lightweight Framework Like Lit or Stencil?

Both are great for standalone web components, but our component library (@dxsolo/ui) is already React + Tailwind. Introducing a second component model would mean maintaining two sets of abstractions for no real benefit. We want the blog series to show how a single ecosystem, React components in a Next.js app with a shared design system, scales from simple layouts to complex interactive features like drag and drop and rich text editing.


Scaffolding the Project

Let's get the project running. We will use create-next-app and wire up our dependencies from there.

Node version: Use Node 20 LTS. Node 25 (the current/unstable release) causes Next.js dev server to exit immediately with no error. If npm run dev starts, prints "Ready", and returns you to the shell prompt, check your Node version first: node --version. Use nvm to switch: nvm install 20 && nvm use 20.

Next.js version: Use Next.js 15, not 16. Next.js 16.2.0 has a bug where the dev server exits cleanly without ever binding to a port. create-next-app@latest may install 16 — we will pin to 15 in Step 2.

Step 1: Create the Next.js App

bash
npx create-next-app@latest solo-kanban \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

This gives us a src/ directory with the App Router structure, TypeScript configured, and Tailwind CSS ready to go.

Step 2: Install Core Dependencies

bash
cd solo-kanban
 
# Component library
npm install @dxsolo/ui
 
# Database
npm install prisma @prisma/client @prisma/adapter-pg pg
npm install -D prisma @types/pg
 
# Pin Next.js to 15 — Next.js 16 has a dev server bug (exits immediately)
npm install next@15 eslint-config-next@15
 
# Force a single React instance to prevent conflicts with @dxsolo/ui
# Add this to package.json manually (see note below)
 
# We'll add more in later parts (drag-and-drop, markdown editor, etc.)

Step 3: Configure Tailwind for @dxsolo/ui

Open your main CSS file (src/app/globals.css) and add the library's theme and source directive:

css
@import "tailwindcss";
@import "@dxsolo/ui/theme.css";
 
@source "../../node_modules/@dxsolo/ui";

That @source directive is critical. It tells Tailwind to scan the @dxsolo/ui package for class names during the build. Without it, the library's components render unstyled, every element looks like a plain div, and you will spend 20 minutes wondering what you broke before checking the CSS imports.

Pitfall: wrong @source path. In Tailwind v4, @source paths are relative to the CSS file, not the project root. Since globals.css lives at src/app/globals.css, you need two levels of ../ to reach node_modules/ at the project root. Using ../node_modules/@dxsolo/ui points to the non-existent src/node_modules/ and the components will be unstyled.

Pitfall: forgetting @source. This is the single most common issue when integrating @dxsolo/ui into a new project. If your buttons have no padding and your cards have no borders, check this line first.

Step 4: Docker for Local PostgreSQL

Create a docker-compose.yml in the project root:

yaml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: kanban
      POSTGRES_PASSWORD: kanban
      POSTGRES_DB: solo_kanban
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
 
volumes:
  pgdata:

Start it up:

bash
docker compose up -d

Step 5: Initialize Prisma

bash
npx prisma init

This creates a prisma/ directory with a schema.prisma file, a .env file, and a prisma.config.ts file. Update .env:

env
DATABASE_URL="postgresql://kanban:kanban@localhost:5432/solo_kanban?schema=public"

Prisma 7 change: connection URL lives in prisma.config.ts, not schema.prisma. Prisma 7 removed the url field from the datasource block in schema.prisma. Instead, the generated prisma.config.ts reads DATABASE_URL from your environment and passes it to Migrate. You do not need to edit prisma.config.ts — the generated file already wires this up correctly:

typescript
// prisma.config.ts (auto-generated, no changes needed)
import "dotenv/config";
import { defineConfig } from "prisma/config";
 
export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: process.env["DATABASE_URL"],
  },
});

If you are following along with older Prisma docs or tutorials that show url = env("DATABASE_URL") inside schema.prisma, remove that line — it will cause a validation error in Prisma 7.


The Data Model

This is where we make the decisions that the rest of the series depends on. Let's think through the entities before we write the schema.

What a Solo Dev Kanban Needs

We are building for a single developer managing their own work. That simplifies things considerably: no teams, no permissions, no multi-tenancy. But we still want enough structure to be useful.

Here is the hierarchy:

  • A Project is the top level. Think of it as a repository or a product.
  • An Epic groups related work within a project. "User authentication" or "Dashboard redesign" are epics.
  • An Issue is a single unit of work. Issues live on the kanban board and move through columns.
  • A Subtask is a checklist item inside an issue. "Write tests for login endpoint" or "Update the Figma mock."

Each issue has a status that corresponds to a board column: OPEN, IN_PROGRESS, REVIEW, or DONE.

Decision: Status as an Enum vs. a Separate Table

We could model board columns as a separate Column table, which would let users create custom columns. But for a solo dev tool, four fixed statuses are plenty. Using an enum keeps queries simple and avoids a join every time we render the board.

If you later want custom columns, you can migrate from the enum to a table. But starting with an enum means less code, fewer moving parts, and a faster path to a working board.

Decision: Issue Ordering

When you drag an issue within a column, you need to persist its position. There are two common approaches:

  1. Integer position column: every issue has an order integer. Reordering means updating multiple rows.
  2. Fractional indexing: positions are stored as strings or decimals between existing items. Reordering usually means updating one row.

We will use integer ordering for now. It is simpler to reason about, easier to debug, and the performance cost of updating a few rows in a single-user app is negligible. We will revisit this in Part 3 when we implement drag and drop.

The Prisma Schema

prisma
// prisma/schema.prisma
 
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
}
 
enum IssueStatus {
  OPEN
  IN_PROGRESS
  REVIEW
  DONE
}
 
enum IssuePriority {
  LOW
  MEDIUM
  HIGH
  URGENT
}
 
model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  epics       Epic[]
  issues      Issue[]
}
 
model Epic {
  id          String   @id @default(cuid())
  name        String
  description String?
  color       String   @default("#6366f1") // indigo-500, for board labels
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  projectId   String
  project     Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
 
  issues      Issue[]
 
  @@index([projectId])
}
 
model Issue {
  id          String        @id @default(cuid())
  title       String
  description String?       // markdown content
  status      IssueStatus   @default(OPEN)
  priority    IssuePriority @default(MEDIUM)
  order       Int           @default(0)
  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt
 
  projectId   String
  project     Project       @relation(fields: [projectId], references: [id], onDelete: Cascade)
 
  epicId      String?
  epic        Epic?         @relation(fields: [epicId], references: [id], onDelete: SetNull)
 
  subtasks    Subtask[]
 
  @@index([projectId, status])
  @@index([epicId])
}
 
model Subtask {
  id          String   @id @default(cuid())
  title       String
  completed   Boolean  @default(false)
  order       Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  issueId     String
  issue       Issue    @relation(fields: [issueId], references: [id], onDelete: Cascade)
 
  @@index([issueId])
}

Let's walk through the key decisions baked into this schema.

cuid() for IDs. CUIDs are URL-safe, sortable by creation time, and collision-resistant. UUIDs work too, but CUIDs are shorter and sort better in indexes.

onDelete: Cascade for issues and subtasks. If you delete a project, its epics, issues, and subtasks go with it. This matches how a solo dev thinks about cleanup: nuke the whole project, not its orphaned children one by one.

onDelete: SetNull for epic references on issues. If you delete an epic, the issues that belonged to it should survive. They just lose their grouping. This prevents accidental data loss.

Composite index on [projectId, status]. This is the query that powers the board: "give me all issues for this project, grouped by status." The composite index makes it fast.

order on both Issue and Subtask. We will need this for drag-and-drop reordering in Part 3 and for subtask reordering in Part 4.

description as a plain String?. We store raw markdown. The rendering happens on the client with the Tiptap editor (Part 4). No need for a separate content JSON column unless we switch to a block-based editor later.

color on Epic. This gives us colored labels on the board without a separate Label model. Each epic gets a default indigo, and users can pick from a palette. Simple and effective.

Run the Migration

bash
npx prisma migrate dev --name init

This creates the tables in your local PostgreSQL. Then generate the Prisma Client:

bash
npx prisma generate

You can verify everything looks right:

bash
npx prisma studio

Prisma Studio opens a browser-based data explorer at http://localhost:5555. You should see your four tables with all the columns we defined.


Seed Data

An empty board is not very useful for development. Let's add some realistic seed data.

Create prisma/seed.ts:

typescript
import "dotenv/config";
import { PrismaClient, IssueStatus, IssuePriority } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
 
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
 
async function main() {
  // Clean existing data
  await prisma.subtask.deleteMany();
  await prisma.issue.deleteMany();
  await prisma.epic.deleteMany();
  await prisma.project.deleteMany();
 
  // Create a project
  const project = await prisma.project.create({
    data: {
      name: "Solo Kanban",
      description: "The kanban board we are building in this blog series",
    },
  });
 
  // Create epics
  const setupEpic = await prisma.epic.create({
    data: {
      name: "Project Setup",
      color: "#6366f1", // indigo
      projectId: project.id,
    },
  });
 
  const boardEpic = await prisma.epic.create({
    data: {
      name: "Board UI",
      color: "#f59e0b", // amber
      projectId: project.id,
    },
  });
 
  const dndEpic = await prisma.epic.create({
    data: {
      name: "Drag and Drop",
      color: "#10b981", // emerald
      projectId: project.id,
    },
  });
 
  // Create issues across all statuses
  const issues = [
    {
      title: "Initialize Next.js project",
      description: "Scaffold the app with create-next-app, install deps, configure Tailwind.",
      status: IssueStatus.DONE,
      priority: IssuePriority.HIGH,
      order: 0,
      epicId: setupEpic.id,
    },
    {
      title: "Set up Prisma schema",
      description: "Define models for Project, Epic, Issue, and Subtask. Run initial migration.",
      status: IssueStatus.DONE,
      priority: IssuePriority.HIGH,
      order: 1,
      epicId: setupEpic.id,
    },
    {
      title: "Build four-column board layout",
      description: "Use CSS Grid for the column layout. Each column represents a status.\n\n## Requirements\n- Responsive down to tablet\n- Scrollable columns when content overflows\n- Column headers show issue count",
      status: IssueStatus.IN_PROGRESS,
      priority: IssuePriority.HIGH,
      order: 0,
      epicId: boardEpic.id,
    },
    {
      title: "Design issue card component",
      description: "Cards show title, priority badge, epic label, and subtask progress.",
      status: IssueStatus.IN_PROGRESS,
      priority: IssuePriority.MEDIUM,
      order: 1,
      epicId: boardEpic.id,
    },
    {
      title: "Implement drag and drop",
      description: "Evaluate dnd-kit vs pragmatic-drag-and-drop. Need cross-column moves and within-column reordering.",
      status: IssueStatus.OPEN,
      priority: IssuePriority.HIGH,
      order: 0,
      epicId: dndEpic.id,
    },
    {
      title: "Add keyboard shortcuts",
      description: "Arrow keys to navigate, Enter to open detail, M to move between columns.",
      status: IssueStatus.OPEN,
      priority: IssuePriority.LOW,
      order: 1,
      epicId: null,
    },
    {
      title: "Write seed data script",
      description: "Realistic sample data for development and screenshots.",
      status: IssueStatus.REVIEW,
      priority: IssuePriority.MEDIUM,
      order: 0,
      epicId: setupEpic.id,
    },
  ];
 
  for (const issue of issues) {
    const created = await prisma.issue.create({
      data: {
        ...issue,
        projectId: project.id,
      },
    });
 
    // Add subtasks to the "Build four-column board layout" issue
    if (issue.title === "Build four-column board layout") {
      await prisma.subtask.createMany({
        data: [
          { title: "Set up CSS Grid container", completed: true, order: 0, issueId: created.id },
          { title: "Create column components", completed: true, order: 1, issueId: created.id },
          { title: "Add overflow scrolling", completed: false, order: 2, issueId: created.id },
          { title: "Test on tablet breakpoint", completed: false, order: 3, issueId: created.id },
        ],
      });
    }
  }
 
  console.log("Seed data created successfully.");
}
 
main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

Prisma 7 change: seed command lives in prisma.config.ts, not package.json. Add it to the migrations section:

typescript
// prisma.config.ts
migrations: {
  path: "prisma/migrations",
  seed: "npx tsx prisma/seed.ts",
},

Install tsx for running TypeScript directly:

bash
npm install -D tsx

Run the seed:

bash
npx prisma db seed

Open Prisma Studio again and you should see a project with three epics, seven issues spread across all four statuses, and four subtasks on one of the issues.


Project Structure

Here is what the src/ directory will look like by the end of the series. For now, we only need the first few files, but it helps to see where we are headed:

plaintext
src/
  app/
    layout.tsx           # root layout, global providers
    page.tsx             # redirect to default project board
    projects/
      [projectId]/
        board/
          page.tsx       # the kanban board (Part 2)
        issues/
          [issueId]/
            page.tsx     # issue detail view (Part 4)
    api/
      issues/
        route.ts         # issue CRUD
        reorder/
          route.ts       # drag-and-drop persistence (Part 3)
  components/
    board/
      Board.tsx          # board container
      Column.tsx         # single status column
      IssueCard.tsx      # draggable card
    issue/
      IssueDetail.tsx    # detail panel
      MarkdownEditor.tsx # Tiptap wrapper (Part 4)
      SubtaskList.tsx    # checklist
    layout/
      AppShell.tsx       # sidebar + main content
      Sidebar.tsx        # project/epic navigation
  lib/
    prisma.ts            # Prisma client singleton
    actions.ts           # server actions

The Prisma Client Singleton

This is a pattern you will see in every Next.js + Prisma project. Without it, hot reload in development creates a new Prisma Client on every save, eventually exhausting your database connections.

Create src/lib/prisma.ts:

typescript
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
const client =
  globalForPrisma.prisma ??
  new PrismaClient({
    adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
  });
 
export const prisma = client;
 
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = client;
}

Prisma 7 change: use a driver adapter. The datasources constructor option is gone in Prisma 7. Instead, pass a PrismaPg adapter with the connection string. Every PrismaClient instantiation — the singleton and any scripts like seed.ts — needs this pattern.

Pitfall: Prisma connection exhaustion in dev. If you skip the singleton pattern and just write new PrismaClient(...) at the top of your files, you will eventually see Error: Too many clients already during development. The singleton stores the client on globalThis, which survives hot module replacement.


A Quick Smoke Test

Let's make sure everything works end to end. Update src/app/page.tsx to fetch and display our seed data:

@dxsolo/ui and React Server Components. The library's components use React APIs like createContext internally but ship without "use client" directives. If you import them directly into a server component, Next.js will try to run them server-side and throw createContext is not a function. The fix is a thin wrapper file that declares the boundary:

Create src/components/ui/index.ts:

typescript
"use client";
 
export { Card, CardContent } from "@dxsolo/ui";

Import from this wrapper instead of @dxsolo/ui directly. We will expand this file as we add more components throughout the series.

React version conflict. If you see createContext is not a function even after adding the wrapper, @dxsolo/ui may be pulling in its own copy of React. Add an overrides block to package.json to force a single instance, then run npm install:

json
"overrides": {
  "react": "19.2.4",
  "react-dom": "19.2.4"
}

Now update src/app/page.tsx:

tsx
import { prisma } from "@/lib/prisma";
import { Card, CardContent } from "@/components/ui";
 
export default async function Home() {
  const projects = await prisma.project.findMany({
    include: {
      _count: {
        select: { issues: true, epics: true },
      },
    },
  });
 
  return (
    <main className="min-h-screen p-8">
      <h1 className="text-2xl font-bold mb-6">Solo Kanban</h1>
      <div className="grid gap-4 max-w-sm">
        {projects.map((project: (typeof projects)[number]) => (
          <Card key={project.id}>
            <CardContent>
              <h2 className="text-lg font-semibold">{project.name}</h2>
              <p className="text-sm text-gray-500 mt-1">
                {project.description}
              </p>
              <p className="text-sm mt-2">
                {project._count.epics} epics, {project._count.issues} issues
              </p>
            </CardContent>
          </Card>
        ))}
      </div>
    </main>
  );
}

Run the dev server:

bash
npm run dev

Navigate to http://localhost:3000. You should see a card showing "Solo Kanban" with "3 epics, 7 issues." If the card has proper styling (borders, padding, rounded corners), your @dxsolo/ui integration is working.

If the card looks unstyled, check two things in order: the @source path in globals.css (needs ../../), and then the "use client" wrapper.


What We Built in Part 1

Here is what we have after this post:

  • A Next.js 15 project with TypeScript, Tailwind CSS v4, and the App Router (Node 20 LTS required)
  • @dxsolo/ui installed and configured with its theme
  • Prisma with a PostgreSQL schema covering Projects, Epics, Issues, and Subtasks
  • Seed data that gives us realistic content for development
  • A smoke test confirming the full stack works

We made a few deliberate tradeoffs worth remembering:

  • Fixed status enum over dynamic columns. Simpler code, easier queries, good enough for a solo dev tool.
  • Integer ordering over fractional indexing. We will revisit the performance implications in Part 3, but for a single-user app this is fine.
  • Markdown stored as a plain string. No JSON content blocks, no separate rich text column. The editor in Part 4 will handle rendering.

What is Next

In Part 2, we build the board itself. Four scrollable columns, CSS Grid layout, container queries for responsive cards, and @dxsolo/ui Card components styled for the kanban context. We will also set up the app shell with a sidebar for project navigation.

See you there.


Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-1

Live demo: Coming in Part 6

@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent