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

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:
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.
Before we write a single line of code, let's talk about why we are reaching for these specific tools.
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.
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.
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.
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.
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.
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.
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.)Open your main CSS file (src/app/globals.css) and add the library's theme and source directive:
@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.
Create a docker-compose.yml in the project root:
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:
docker compose up -dnpx prisma initThis creates a prisma/ directory with a schema.prisma file, a .env file, and a prisma.config.ts file. Update .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:
// 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.
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.
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:
Each issue has a status that corresponds to a board column: OPEN, IN_PROGRESS, REVIEW, or DONE.
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.
When you drag an issue within a column, you need to persist its position. There are two common approaches:
order integer. Reordering means updating multiple rows.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.
// 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.
npx prisma migrate dev --name initThis creates the tables in your local PostgreSQL. Then generate the Prisma Client:
npx prisma generateYou can verify everything looks right:
npx prisma studioPrisma Studio opens a browser-based data explorer at http://localhost:5555. You should see your four tables with all the columns we defined.
An empty board is not very useful for development. Let's add some realistic seed data.
Create prisma/seed.ts:
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:
// prisma.config.ts
migrations: {
path: "prisma/migrations",
seed: "npx tsx prisma/seed.ts",
},Install tsx for running TypeScript directly:
npm install -D tsxRun the seed:
npx prisma db seedOpen 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.
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:
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 actionsThis 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:
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.
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:
"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:
"overrides": {
"react": "19.2.4",
"react-dom": "19.2.4"
}Now update src/app/page.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:
npm run devNavigate 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.
Here is what we have after this post:
@dxsolo/ui installed and configured with its themeWe made a few deliberate tradeoffs worth remembering:
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