Pawboard is a real-time collaborative ideation board with a cat theme, built with Next.js 16, React 19, Supabase Realtime, and Drizzle ORM.
# Development
bun dev # Start Next.js dev server
# Build
bun build # Production build
bun start # Start production server
# Linting
bun check # Run Biome linter
# Database
bun db:generate # Generate migrations from schema changes
bun db:generate:local # Generate migrations (using local env)
bun db:migrate # Apply migrations to database
bun db:migrate:local # Apply migrations to local Supabase
bun db:seed:local # Seed local database with test data
# Local Supabase
bun supabase:start # Start local Supabase stack (Docker)
bun supabase:stop # Stop local Supabase
bun supabase:status # Check local Supabase status
bun supabase:reset # Reset local DB and run seed.sqlNote: No test framework is configured. When adding tests, use Vitest or Jest with React Testing Library.
- Docker Desktop (or compatible container runtime)
- Bun
- Copy
.env.local.exampleto.env.local - Start local Supabase:
bun supabase:start - Copy the
anon keyfrom the CLI output to your.env.local - Apply migrations to local DB:
bun db:migrate:local - Seed the database:
bun db:seed:local - Start dev server:
bun dev
bun supabase:start # Start local Supabase (if not running)
bun dev # Start Next.js dev server| Environment | Database | Supabase Realtime | Configuration |
|---|---|---|---|
| Local | Docker (port 54322) | Docker (port 54321) | .env.local |
| Preview | pawboard-dev project | pawboard-dev project | Vercel Preview env vars |
| Production | pawboard project | pawboard project | Vercel Production env vars |
- Next.js: http://localhost:3000
- Supabase Studio: http://localhost:54323
- Supabase API: http://localhost:54321
- PostgreSQL: localhost:54322
| Category | Technology |
|---|---|
| Framework | Next.js 16 (App Router, RSC) |
| React | React 19 |
| Language | TypeScript 5 (strict mode) |
| Database | PostgreSQL via Drizzle ORM |
| Realtime | Supabase Realtime (channels, presence) |
| Styling | Tailwind CSS 4 + shadcn/ui (new-york) |
| State | Zustand |
| AI | Vercel AI SDK + Groq |
| Animation | Motion (Framer Motion v12) |
| Package | Bun |
Order imports as follows (no blank lines between groups):
- React/Next.js core (
react,next/*) - Third-party libraries (
@supabase/*,drizzle-orm,lucide-react,motion/*) - Local components (
@/components/*) - Local hooks (
@/hooks/*) - Local utilities (
@/lib/*) - Database imports (
@/db/*) - Types (use
typekeyword for type-only imports)
import { useState, useEffect, useCallback } from "react";
import { useTheme } from "next-themes";
import { motion, AnimatePresence } from "motion/react";
import { Textarea } from "@/components/ui/textarea";
import { useRealtimeCards } from "@/hooks/use-realtime-cards";
import { cn } from "@/lib/utils";
import type { Card } from "@/db/schema";| Element | Convention | Example |
|---|---|---|
| Files | kebab-case | use-realtime-cards.ts |
| Components | PascalCase | IdeaCard, RealtimeCursors |
| Hooks | camelCase, use-prefix | useRealtimeCards, useFingerprint |
| Functions | camelCase | generateSessionId, createCard |
| Constants | SCREAMING_SNAKE_CASE | STORAGE_KEY, THROTTLE_MS |
| Types | PascalCase | Card, NewCard, Session |
- Strict mode is enabled - never use
any - Prefer type inference; add explicit types when not obvious
- Use
typekeyword for type-only imports:import type { Card } from "@/db/schema" - Use Drizzle's
$inferSelectand$inferInsertfor DB types - Use
React.ComponentProps<"element">for extending HTML element props
// Drizzle type inference
export type Card = typeof cards.$inferSelect;
export type NewCard = typeof cards.$inferInsert;
// Component props
interface IdeaCardProps extends React.ComponentProps<"div"> {
card: Card;
onMove: (id: string, x: number, y: number) => void;
}Use try-catch with { data, error } return pattern for server actions:
export async function createCard(data: NewCard) {
try {
const card = await db.insert(cards).values(data).returning();
return { card: card[0], error: null };
} catch (error) {
console.error("Database error in createCard:", error);
return { card: null, error: "Failed to create card" };
}
}For client-side hooks, use try-catch with console.error and fallback values.
- Use
"use client"directive at top of client components - Use
"use server"directive for server actions (inapp/actions.ts) - Dynamic routes use
Promise<{ params }>pattern:
interface Props {
params: Promise<{ sessionId: string }>;
}
export default async function SessionPage({ params }: Props) {
const { sessionId } = await params;
// ...
}Define in app/actions.ts with "use server" directive:
"use server";
export async function createCard(data: NewCard) {
// Validation, DB operation, return { data, error }
}Use route handlers in app/api/*/route.ts:
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const body = await req.json();
// Process and return NextResponse.json()
}Use channels with typed event payloads:
type CardEvent =
| { type: "card:add"; card: Card }
| { type: "card:move"; id: string; x: number; y: number }
| { type: "card:update"; id: string; content: string };
channel.on("broadcast", { event: "card-event" }, ({ payload }) => {
const event = payload as CardEvent;
// Handle typed event
});Schema in db/schema.ts, client in db/index.ts:
// Schema
export const cards = pgTable("cards", {
id: text("id").primaryKey(),
sessionId: text("session_id")
.notNull()
.references(() => sessions.id, { onDelete: "cascade" }),
content: text("content").notNull(),
x: integer("x").notNull().default(0),
y: integer("y").notNull().default(0),
});- Functional components only
- Custom hooks for reusable logic (in
hooks/) - Use
useCallbackanduseReffor performance - Throttle realtime updates (typically 50-100ms)
- Use Tailwind CSS classes
- Use
cn()utility from@/lib/utilsfor conditional classes - shadcn/ui components in
components/ui/ - CSS variables defined in
app/globals.css - Dark mode via
next-themeswith class strategy
import { cn } from "@/lib/utils";
<div className={cn("rounded-lg p-4", isActive && "border-primary")} />app/
[sessionId]/page.tsx # Dynamic session board
api/*/route.ts # API routes
actions.ts # Server actions
layout.tsx # Root layout with providers
page.tsx # Home page
components/
ui/ # shadcn/ui components (DO NOT EDIT)
elements/ # Custom reusable elements
*.tsx # Feature components
db/
index.ts # Database client
schema.ts # Drizzle schema
hooks/ # Custom React hooks (use-*.ts)
lib/
supabase/ # Supabase clients
utils.ts # Utility functions
stores/ # Zustand stores
Required in .env.local:
DATABASE_URL=postgresql://...
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=eyJ...
GROQ_API_KEY=gsk_...
NEXT_PUBLIC_SITE_URL=https://pawboard.app
Commit messages must follow the format: type(scope): description
Valid types:
feat- New featurefix- Bug fixdocs- Documentation changesstyle- Code style changes (formatting, no logic change)refactor- Code refactoring (no feature or fix)perf- Performance improvementstest- Adding or updating testschore- Maintenance tasksrevert- Reverting changes
Examples:
feat(auth): add login functionality
fix(cards): prevent duplicate card creation
refactor(cluster): simplify dialog to single step
docs(readme): update setup instructions
chore(deps): upgrade drizzle-orm to v0.35
Do:
- Use path aliases (
@/components,@/hooks, etc.) - Add new shadcn/ui components via
bunx shadcn@latest add <component> - Use server components by default, add
"use client"only when needed - Use optimistic UI updates with server persistence
- Follow existing patterns in the codebase
Don't:
- Edit files in
components/ui/directly (managed by shadcn/ui) - Use
anytype - Skip error handling in server actions
- Use
npmoryarn(project uses Bun) - Commit
.env.localor secrets - Run database migrations directly - only edit
db/schema.ts, user will apply migrations manually