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

Welcome back. In Part 1, we scaffolded the project, designed the Prisma schema, seeded the database, and confirmed that @dxsolo/ui renders correctly. Now we build the thing people actually see: the kanban board.
By the end of this post, you will have four scrollable columns (Open, InProgress, Review, Done), responsive issue cards powered by @dxsolo/ui, an app shell with a sidebar, and a layout that adapts from desktop down to tablet using CSS Grid and container queries.
Series overview:
Before we touch the board, we need a layout wrapper. Every page in the app shares a sidebar for project navigation and a main content area. Let's build that first.
The app shell uses a simple two-column CSS Grid: a fixed-width sidebar on the left and a fluid main area that takes the remaining space. We are not using flexbox here because Grid gives us explicit control over the sidebar width without flex-shrink surprises, and it plays nicely with the nested grid we will use for the board columns.
Create src/components/layout/AppShell.tsx:
"use client";
import { ReactNode } from "react";
interface AppShellProps {
sidebar: ReactNode;
children: ReactNode;
}
export function AppShell({ sidebar, children }: AppShellProps) {
return (
<div className="grid grid-cols-[260px_1fr] h-screen overflow-hidden">
<aside className="border-r border-gray-200 bg-gray-50 overflow-y-auto">
{sidebar}
</aside>
<main className="overflow-hidden">{children}</main>
</div>
);
}A few things to notice:
grid-cols-[260px_1fr] gives us a fixed 260px sidebar and a fluid main area. The bracket syntax is Tailwind's arbitrary value support for Grid template columns.h-screen overflow-hidden on the outer container locks the app to the viewport. No page-level scrolling. Each section (sidebar and main) manages its own overflow.overflow-y-auto on the aside lets the sidebar scroll independently when the project list gets long.overflow-hidden on main prevents double scrollbars. The board columns inside will handle their own vertical scrolling.Pitfall: page-level scroll bleeding. If you forget overflow-hidden on the outer grid, the entire page scrolls when a column overflows. This makes the board feel broken because the column headers scroll out of view. Lock the viewport at the shell level, then give each child its own scroll context.
For now, the sidebar is simple: a project name at the top and a placeholder for epic filters (we will flesh this out in Part 5). We will use @dxsolo/ui components where they fit.
Create src/components/layout/Sidebar.tsx:
"use client";
import { Button } from "@dxsolo/ui";
interface SidebarProps {
projectName: string;
epicCount: number;
issueCount: number;
}
export function Sidebar({ projectName, epicCount, issueCount }: SidebarProps) {
return (
<div className="p-4 flex flex-col gap-4">
<div>
<h1 className="text-lg font-bold">{projectName}</h1>
<p className="text-sm text-gray-500">
{epicCount} epics, {issueCount} issues
</p>
</div>
<nav className="flex flex-col gap-1">
<Button intent="ghost" className="justify-start w-full">
Board
</Button>
<Button intent="ghost" className="justify-start w-full">
Backlog
</Button>
<Button intent="ghost" className="justify-start w-full">
Settings
</Button>
</nav>
<div className="border-t border-gray-200 pt-4">
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
Epics
</h2>
<p className="text-sm text-gray-500">
Epic filters coming in Part 5.
</p>
</div>
</div>
);
}Nothing fancy. The Button component from @dxsolo/ui with intent="ghost" gives us clean, hover-responsive nav items without writing custom styles. We override alignment with justify-start so the text sits left instead of centered.
Update the project board route. Create src/app/projects/[projectId]/board/page.tsx:
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { AppShell } from "@/components/layout/AppShell";
import { Sidebar } from "@/components/layout/Sidebar";
interface BoardPageProps {
params: Promise<{ projectId: string }>;
}
export default async function BoardPage({ params }: BoardPageProps) {
const { projectId } = await params;
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
epics: true,
issues: {
include: {
epic: true,
subtasks: true,
},
orderBy: { order: "asc" },
},
_count: {
select: { epics: true, issues: true },
},
},
});
if (!project) notFound();
return (
<AppShell
sidebar={
<Sidebar
projectName={project.name}
epicCount={project._count.epics}
issueCount={project._count.issues}
/>
}
>
<div className="flex items-center justify-center h-full">
<p className="text-gray-500">Board coming soon.</p>
</div>
</AppShell>
);
}Notice we are not importing or rendering a Board component yet — it does not exist. Instead, we have a centered placeholder in the main area. We will swap this out once the board components are built.
This is a React Server Component. The database query runs on the server, and we pass the data down as props. No client-side fetching, no loading spinners, no waterfall requests.
We also need a redirect from the home page to the default project board. Update src/app/page.tsx:
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export default async function Home() {
const project = await prisma.project.findFirst({
orderBy: { createdAt: "asc" },
});
if (project) {
redirect(`/projects/${project.id}/board`);
}
return (
<main className="flex items-center justify-center h-screen">
<p className="text-gray-500">No projects yet. Create one to get started.</p>
</main>
);
}Start the dev server:
npm run devVisit http://localhost:3000. The home page should redirect you to your project's board route. You should see:
If you see this, the app shell is wired up correctly. Keep the dev server running as we build the board components next.
Now the main event. The board is a horizontal grid of four columns, one for each status. Each column has a sticky header and a scrollable list of issue cards.
Flexbox can do horizontal layouts, but Grid is the better fit here for two reasons:
grid-template-columns: repeat(4, 1fr) and you are done. With flexbox, you need flex: 1 on each child and have to deal with min-width: 0 to prevent content from blowing out the column width.minmax(0, 1fr) pattern does exactly that without surprises.Create src/components/board/Board.tsx:
"use client";
import { IssueStatus } from "@prisma/client";
import { Column } from "./Column";
import type { IssueWithRelations } from "@/types";
const COLUMNS: { status: IssueStatus; label: string }[] = [
{ status: "OPEN", label: "Open" },
{ status: "IN_PROGRESS", label: "In Progress" },
{ status: "REVIEW", label: "Review" },
{ status: "DONE", label: "Done" },
];
interface BoardProps {
issues: IssueWithRelations[];
}
export function Board({ issues }: BoardProps) {
const issuesByStatus = COLUMNS.map((col) => ({
...col,
issues: issues.filter((issue) => issue.status === col.status),
}));
return (
<div className="h-full p-4 overflow-x-auto">
<div className="grid grid-cols-4 gap-4 h-full min-w-[800px]">
{issuesByStatus.map((col) => (
<Column
key={col.status}
status={col.status}
label={col.label}
issues={col.issues}
/>
))}
</div>
</div>
);
}Let's define the type we referenced. Create src/types/index.ts:
import type { Issue, Epic, Subtask } from "@prisma/client";
export type IssueWithRelations = Issue & {
epic: Epic | null;
subtasks: Subtask[];
};A few design decisions in the Board component:
**min-w-[800px] with overflow-x-auto.** On narrow viewports (phones), four columns cannot fit. Instead of collapsing to a vertical stack, which breaks the kanban mental model, we allow horizontal scrolling. This keeps the spatial relationship between columns intact. The 800px minimum ensures each column gets at least 200px before scrolling kicks in.
Client component. We marked this "use client" because the board will eventually handle drag-and-drop state, keyboard navigation, and other interactive behaviors. Even though the initial render could technically be a server component, making it a client component now avoids a refactor later.
Grouping issues by status on the client. We could have done this grouping in the server component or even in the database query with separate queries per status. But a single query with client-side grouping is simpler, results in one database round trip, and the filtering cost is negligible for the number of issues a solo dev will have.
Create src/components/board/Column.tsx:
"use client";
import { IssueStatus } from "@prisma/client";
import { IssueCard } from "./IssueCard";
import type { IssueWithRelations } from "@/types";
interface ColumnProps {
status: IssueStatus;
label: string;
issues: IssueWithRelations[];
}
const statusColors: Record<IssueStatus, string> = {
OPEN: "bg-gray-100 text-gray-700",
IN_PROGRESS: "bg-blue-100 text-blue-700",
REVIEW: "bg-amber-100 text-amber-700",
DONE: "bg-green-100 text-green-700",
};
export function Column({ status, label, issues }: ColumnProps) {
return (
<div className="flex flex-col h-full min-h-0 rounded-lg bg-gray-50">
{/* Column header: sticky, never scrolls */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<div className="flex items-center gap-2">
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${statusColors[status]}`}
>
{label}
</span>
<span className="text-xs text-gray-400">{issues.length}</span>
</div>
</div>
{/* Card list: scrollable */}
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
{issues.map((issue) => (
<IssueCard key={issue.id} issue={issue} />
))}
{issues.length === 0 && (
<p className="text-xs text-gray-400 text-center py-8">
No issues
</p>
)}
</div>
</div>
);
}The critical CSS pattern here is the column's internal layout:
flex flex-col h-full min-h-0
├── header (fixed height, never scrolls)
└── card list (flex-1 overflow-y-auto, scrolls independently)**min-h-0 is the key.** Without it, the flex column will not shrink below its content height, and overflow-y-auto on the card list does nothing. This is one of the most common CSS Grid/Flexbox pitfalls: flex items default to min-height: auto, which means they grow to fit their content. Setting min-h-0 tells the column, "you are allowed to be smaller than your content, let the children scroll."
Pitfall: the invisible overflow. You build the columns, add 20 cards, and they all render but the column just stretches. No scrollbar appears. You check overflow-y-auto and it is there. The problem is almost always a missing min-h-0 somewhere in the flex/grid chain between the viewport and the scrollable container. Every ancestor in the chain needs to constrain its height.
Now that Board, Column, and the types exist, swap out the placeholder in src/app/projects/[projectId]/board/page.tsx. Add the Board import at the top and replace the placeholder div with the Board component:
import { AppShell } from "@/components/layout/AppShell";
import { Sidebar } from "@/components/layout/Sidebar";
+ import { Board } from "@/components/board/Board"; >
- <div className="flex items-center justify-center h-full">
- <p className="text-gray-500">Board coming soon.</p>
- </div>
+ <Board issues={project.issues} />
</AppShell>We still need IssueCard before the app will compile. Create a temporary stub to see the columns in action. Create src/components/board/IssueCard.tsx:
import type { IssueWithRelations } from "@/types";
export function IssueCard({ issue }: { issue: IssueWithRelations }) {
return (
<div className="p-2 bg-white rounded border text-sm">{issue.title}</div>
);
}Visit http://localhost:3000 (or let the dev server hot-reload). You should see:
This stub is temporary — we will replace it with the full IssueCard component in the next section.
The issue card is the most visible component on the board. It shows the title, a priority badge, an optional epic label, and a subtask progress indicator. We want cards to adapt their layout based on the column width, not the viewport width. This is exactly what CSS container queries are for.
Media queries respond to the browser viewport. Container queries respond to the size of a parent element. On our board, all four columns are the same width, so media queries would work fine, right? Yes, for now. But in Part 5, when we add the ability to collapse or expand the sidebar, the column widths change without the viewport changing. And if we ever add a detail panel that shrinks the board, same thing.
Container queries future-proof the cards against layout changes they do not control. The card says, "if my container is narrower than 200px, hide the epic label." It does not care why the container is narrow.
The column's card list becomes a container query context. In Column.tsx, find the card list div and add @container to its class list. The only change is appending @container to the end of the existing classes:
- <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
+ <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2 @container">Do not wrap the card list in an additional <div className="@container">. The @container class goes directly on the existing scrollable div. This is important because separating the container query context from the scroll container can cause width-reporting issues (covered in the pitfalls section below).
The @container class (Tailwind's container query utility) marks this div as a containment context. Any child can now use @[width]: prefixed utilities to respond to this container's width.
Replace the stub in src/components/board/IssueCard.tsx with the full implementation:
"use client";
import { Card, CardContent } from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssuePriority } from "@prisma/client";
interface IssueCardProps {
issue: IssueWithRelations;
}
const priorityConfig: Record<
IssuePriority,
{ label: string; className: string }
> = {
URGENT: { label: "Urgent", className: "bg-red-100 text-red-700" },
HIGH: { label: "High", className: "bg-orange-100 text-orange-700" },
MEDIUM: { label: "Med", className: "bg-yellow-100 text-yellow-700" },
LOW: { label: "Low", className: "bg-gray-100 text-gray-500" },
};
export function IssueCard({ issue }: IssueCardProps) {
const priority = priorityConfig[issue.priority];
const completedSubtasks = issue.subtasks.filter((s) => s.completed).length;
const totalSubtasks = issue.subtasks.length;
return (
<Card className="cursor-pointer hover:ring-2 hover:ring-blue-300 transition-shadow">
<CardContent className="p-3">
{/* Title */}
<h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
{/* Meta row: priority + epic label */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${priority.className}`}
>
{priority.label}
</span>
{/* Epic label: hidden when container is too narrow */}
{issue.epic && (
<span
className="text-xs px-1.5 py-0.5 rounded hidden @[200px]:inline-block"
style={{ backgroundColor: `${issue.epic.color}20`, color: issue.epic.color }}
>
{issue.epic.name}
</span>
)}
</div>
{/* Subtask progress */}
{totalSubtasks > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{
width: `${(completedSubtasks / totalSubtasks) * 100}%`,
}}
/>
</div>
<span className="text-xs text-gray-400">
{completedSubtasks}/{totalSubtasks}
</span>
</div>
)}
</CardContent>
</Card>
);
}Let's break down the container query usage:
<span className="hidden @[200px]:inline-block">This says: "hide this element by default, but show it as inline-block when the nearest @container ancestor is at least 200px wide." When the board columns are wide enough, you see the epic label. When they are narrow (small screen, expanded sidebar, or collapsed view), the epic label hides to save space. The priority badge and title always show.
Pitfall: container queries inside scrollable overflow columns. There is a subtle issue with container queries and overflow: auto. The CSS contain property (which @container implies) can interact with overflow in unexpected ways on some browsers. Specifically, if the container element itself has overflow: hidden or overflow: auto, the containment can sometimes cause the container to report a width of 0.
The fix: make sure the @container is on the scrollable div, not on a wrapper inside it. In our case, the card list div is both the scroll container and the query container, which works correctly. If you separate them (one div for scrolling, a child div for containment), test thoroughly.
You probably noticed we used inline style for the epic label background:
style={{ backgroundColor: `${issue.epic.color}20`, color: issue.epic.color }}The 20 suffix adds 12% opacity to the hex color, giving us a tinted background. We cannot use Tailwind classes here because the epic color is dynamic, stored in the database. Tailwind needs to know class names at build time, and bg-[${someVariable}] does not work. Inline styles are the correct approach for truly dynamic values.
Decision rationale: we could have mapped epic colors to a fixed set of Tailwind color classes (like bg-indigo-100, bg-amber-100) and stored the class name in the database. That works if you want a constrained palette. We chose raw hex colors because it gives users more freedom and makes the color picker in Part 5 simpler to implement.
Visit http://localhost:3000 (or let the dev server hot-reload). The plain white stub cards are gone, replaced by the full IssueCard component. You should now see:
Try resizing your browser window. When the columns get narrow, the epic labels disappear — that is the container query at work. The priority badge and title always remain visible. Widen the window and the epic labels come back.
Let's talk about how the board adapts across screen sizes.
Full layout: 260px sidebar, four equal columns in the remaining space. Each column is roughly 170-200px wide depending on the viewport. Epic labels are visible. Everything fits.
The sidebar could eat too much space at 260px. We have a few options:
For this series, we will go with option 2: a collapsible sidebar. Update AppShell.tsx to support this:
"use client";
import { ReactNode, useState } from "react";
import { Button } from "@dxsolo/ui";
interface AppShellProps {
sidebar: ReactNode;
children: ReactNode;
}
export function AppShell({ sidebar, children }: AppShellProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div
className={`grid h-screen overflow-hidden transition-[grid-template-columns] duration-200 ${
sidebarOpen ? "grid-cols-[260px_1fr]" : "grid-cols-[0px_1fr]"
}`}
>
<aside
className={`border-r border-gray-200 bg-gray-50 overflow-y-auto overflow-x-hidden transition-opacity duration-200 ${
sidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
{sidebar}
</aside>
<main className="overflow-hidden relative">
{/* Sidebar toggle */}
<Button
intent="ghost"
className="absolute top-2 left-2 z-10"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? "◀" : "▶"}
</Button>
<div className="h-full pt-10">{children}</div>
</main>
</div>
);
}When the sidebar collapses, the board columns gain 260px of extra width. Because we used 1fr for the main area, the columns expand automatically. And because our issue cards use container queries instead of media queries, the epic labels appear or hide based on the actual column width, not the viewport. This is container queries doing exactly what they were designed for.
**transition-[grid-template-columns]** gives us a smooth animation when toggling. Tailwind's arbitrary property syntax lets us transition any CSS property, including grid template columns.
A four-column kanban board does not work on a phone. Period. The columns would be too narrow to read. For a real production app, you would build an entirely different mobile view, probably a vertical list grouped by status, or a single-column view where you swipe between statuses.
For this series, we will keep it simple: the board scrolls horizontally on mobile with the min-w-[800px] we set earlier. It is functional, just not optimized. A mobile-specific view is a great exercise for readers who want to extend the project.
Small detail that makes a big difference: visual differentiation between columns. Right now the column headers have colored badges, but the columns themselves all look the same. Let's add a subtle top border to each column that matches its status color.
Update the column wrapper in Column.tsx:
const statusBorderColors: Record<IssueStatus, string> = {
OPEN: "border-t-gray-400",
IN_PROGRESS: "border-t-blue-500",
REVIEW: "border-t-amber-500",
DONE: "border-t-green-500",
};
export function Column({ status, label, issues }: ColumnProps) {
return (
<div
className={`flex flex-col h-full min-h-0 rounded-lg bg-gray-50 border-t-2 ${statusBorderColors[status]}`}
>
{/* ... rest unchanged */}
</div>
);
}A 2px colored top border on each column gives instant visual orientation. When you are scanning the board, your eye uses the color to confirm which column you are looking at before you read the label. It is a tiny detail, but kanban boards live and die by scanability.
An empty column should not just be blank. It should communicate that it is a valid drop target (important for Part 3) and gently encourage action.
We already have basic empty state text in the Column component. Let's make it slightly more useful by varying the message per status.
First, add the emptyMessages map alongside the other constants at the top of Column.tsx (next to statusColors and statusBorderColors):
const emptyMessages: Record<IssueStatus, string> = {
OPEN: "No open issues. Create one to get started.",
IN_PROGRESS: "Nothing in progress. Drag an issue here.",
REVIEW: "Review queue is empty.",
DONE: "No completed issues yet.",
};Then, inside the card list div, replace the existing empty state paragraph with the new dashed-border version:
{issues.length === 0 && (
- <p className="text-xs text-gray-400 text-center py-8">
- No issues
- </p>
+ <div className="flex items-center justify-center h-32 border-2 border-dashed border-gray-200 rounded-lg">
+ <p className="text-xs text-gray-400 text-center px-4">
+ {emptyMessages[status]}
+ </p>
+ </div>
)}The dashed border hints that this is a drop zone. We will make it an actual drop target in Part 3.
Visit http://localhost:3000 and verify the final round of changes:
Here is every file we created or modified in this part, listed for quick reference:
| File | Purpose |
|---|---|
src/components/layout/AppShell.tsx | Two-column grid shell with collapsible sidebar |
src/components/layout/Sidebar.tsx | Project info and navigation |
src/components/board/Board.tsx | Four-column grid, groups issues by status |
src/components/board/Column.tsx | Single status column with sticky header and scrollable cards |
src/components/board/IssueCard.tsx | Card with priority badge, epic label, subtask progress |
src/types/index.ts | Shared TypeScript types |
src/app/projects/[projectId]/board/page.tsx | Board route, fetches data server-side |
src/app/page.tsx | Redirects to default project board |
Here is what we accomplished:
repeat(4, 1fr)min-h-0 pattern for proper overflow@dxsolo/ui Card and CardContent, with priority badges, epic labels, and subtask progress barsmin-w-[800px]Key pitfalls we covered:
**min-h-0 on flex columns.** Without it, overflow scrolling silently breaks.@container from the overflow-y-auto element can cause width reporting issues.**@source in CSS.** Still worth repeating from Part 1, if your @dxsolo/ui components look unstyled, check this first.If something looks off at any point, check these common issues:
@source directive in src/app/globals.css includes the @dxsolo/ui package path, as covered in Part 1.min-h-0 in the flex/grid chain. Every ancestor between the viewport and the scrollable card list needs to constrain its height.@container class is on the card list div in Column.tsx, and that IssueCard.tsx uses @[200px]:inline-block on the epic label span.npx prisma db push and npx prisma db seed from Part 1.createContext is not a function: a component that imports from @dxsolo/ui is missing "use client" at the top of the file.In Part 3, we make the board interactive. Drag and drop: moving issues between columns, reordering within a column, persisting the new positions to the database, handling optimistic updates, and dealing with the many edge cases that make drag and drop genuinely hard. We will evaluate the options (HTML Drag and Drop API, dnd-kit, pragmatic-drag-and-drop) and pick the one that fits best.
See you there.
Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-2
Live demo: Coming in Part 6
@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent