diff --git a/apps/web/src/app/(authenticated)/projects/page.tsx b/apps/web/src/app/(authenticated)/projects/page.tsx
new file mode 100644
index 0000000..6d97d80
--- /dev/null
+++ b/apps/web/src/app/(authenticated)/projects/page.tsx
@@ -0,0 +1,809 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import type { ReactElement, SyntheticEvent } from "react";
+import { useRouter } from "next/navigation";
+import { Plus, Trash2 } from "lucide-react";
+
+import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
+import type { Project, CreateProjectDto } from "@/lib/api/projects";
+import { useWorkspaceId } from "@/lib/hooks";
+
+/* ---------------------------------------------------------------------------
+ Status badge helpers
+ --------------------------------------------------------------------------- */
+
+interface StatusStyle {
+ label: string;
+ bg: string;
+ color: string;
+}
+
+function getStatusStyle(status: ProjectStatus): StatusStyle {
+ switch (status) {
+ case ProjectStatus.PLANNING:
+ return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
+ case ProjectStatus.ACTIVE:
+ return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
+ case ProjectStatus.PAUSED:
+ return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
+ case ProjectStatus.COMPLETED:
+ return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
+ case ProjectStatus.ARCHIVED:
+ return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ default:
+ return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ }
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ ProjectCard
+ --------------------------------------------------------------------------- */
+
+interface ProjectCardProps {
+ project: Project;
+ onDelete: (id: string) => void;
+ onClick: (id: string) => void;
+}
+
+function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
+ const [hovered, setHovered] = useState(false);
+ const status = getStatusStyle(project.status);
+
+ return (
+
{
+ onClick(project.id);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick(project.id);
+ }
+ }}
+ onMouseEnter={() => {
+ setHovered(true);
+ }}
+ onMouseLeave={() => {
+ setHovered(false);
+ }}
+ style={{
+ background: "var(--surface)",
+ border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`,
+ borderRadius: "var(--r-lg)",
+ padding: 20,
+ cursor: "pointer",
+ transition: "border-color 0.2s var(--ease)",
+ display: "flex",
+ flexDirection: "column",
+ gap: 12,
+ position: "relative",
+ }}
+ >
+ {/* Header row: name + delete button */}
+
+
+
+ {project.name}
+
+
+
+ {/* Delete button */}
+
+
+
+ {/* Description */}
+ {project.description ? (
+
+ {project.description}
+
+ ) : (
+
+ No description
+
+ )}
+
+ {/* Footer: status + timestamps */}
+
+ {/* Status badge */}
+
+ {status.label}
+
+
+ {/* Timestamps */}
+
+ {formatTimestamp(project.createdAt)}
+
+
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Create Project Dialog
+ --------------------------------------------------------------------------- */
+
+interface CreateDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (data: CreateProjectDto) => Promise;
+ isSubmitting: boolean;
+}
+
+function CreateProjectDialog({
+ open,
+ onOpenChange,
+ onSubmit,
+ isSubmitting,
+}: CreateDialogProps): ReactElement {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [formError, setFormError] = useState(null);
+
+ function resetForm(): void {
+ setName("");
+ setDescription("");
+ setFormError(null);
+ }
+
+ async function handleSubmit(e: SyntheticEvent): Promise {
+ e.preventDefault();
+ setFormError(null);
+
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ setFormError("Project name is required.");
+ return;
+ }
+
+ try {
+ const payload: CreateProjectDto = { name: trimmedName };
+ const trimmedDesc = description.trim();
+ if (trimmedDesc) {
+ payload.description = trimmedDesc;
+ }
+ await onSubmit(payload);
+ resetForm();
+ } catch (err: unknown) {
+ setFormError(err instanceof Error ? err.message : "Failed to create project.");
+ }
+ }
+
+ return (
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Delete Confirmation Dialog
+ --------------------------------------------------------------------------- */
+
+interface DeleteDialogProps {
+ open: boolean;
+ projectName: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ isDeleting: boolean;
+}
+
+function DeleteConfirmDialog({
+ open,
+ projectName,
+ onConfirm,
+ onCancel,
+ isDeleting,
+}: DeleteDialogProps): ReactElement {
+ return (
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Projects Page
+ --------------------------------------------------------------------------- */
+
+export default function ProjectsPage(): ReactElement {
+ const router = useRouter();
+ const workspaceId = useWorkspaceId();
+
+ const [projects, setProjects] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Create dialog state
+ const [createOpen, setCreateOpen] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+
+ // Delete dialog state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const loadProjects = useCallback(async (wsId: string | null): Promise => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const data = await fetchProjects(wsId ?? undefined);
+ setProjects(data);
+ } catch (err: unknown) {
+ console.error("[Projects] Failed to fetch projects:", err);
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Something went wrong loading projects. You could try again when ready."
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!workspaceId) {
+ setIsLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ const wsId = workspaceId;
+
+ async function load(): Promise {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const data = await fetchProjects(wsId);
+ if (!cancelled) {
+ setProjects(data);
+ }
+ } catch (err: unknown) {
+ console.error("[Projects] Failed to fetch projects:", err);
+ if (!cancelled) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Something went wrong loading projects. You could try again when ready."
+ );
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void load();
+
+ return (): void => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ function handleRetry(): void {
+ void loadProjects(workspaceId);
+ }
+
+ async function handleCreate(data: CreateProjectDto): Promise {
+ setIsCreating(true);
+ try {
+ await createProject(data, workspaceId ?? undefined);
+ setCreateOpen(false);
+ void loadProjects(workspaceId);
+ } finally {
+ setIsCreating(false);
+ }
+ }
+
+ function handleDeleteRequest(projectId: string): void {
+ const target = projects.find((p) => p.id === projectId);
+ if (target) {
+ setDeleteTarget(target);
+ }
+ }
+
+ async function handleDeleteConfirm(): Promise {
+ if (!deleteTarget) return;
+ setIsDeleting(true);
+ try {
+ await deleteProject(deleteTarget.id, workspaceId ?? undefined);
+ setDeleteTarget(null);
+ void loadProjects(workspaceId);
+ } catch (err: unknown) {
+ console.error("[Projects] Failed to delete project:", err);
+ setError(err instanceof Error ? err.message : "Failed to delete project.");
+ setDeleteTarget(null);
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ function handleCardClick(projectId: string): void {
+ router.push(`/projects/${projectId}`);
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Projects
+
+
+ Organize and track your work across different initiatives
+
+
+
+
+
+
+ {/* Loading */}
+ {isLoading ? (
+
+
+
+ ) : error !== null ? (
+ /* Error */
+
+
{error}
+
+
+ ) : projects.length === 0 ? (
+ /* Empty */
+
+
+ No projects yet. Create your first project to get started.
+
+
+
+ ) : (
+ /* Projects grid */
+
+ {projects.map((project) => (
+
+ ))}
+
+ )}
+
+ {/* Create Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+ {
+ void handleDeleteConfirm();
+ }}
+ onCancel={() => {
+ setDeleteTarget(null);
+ }}
+ isDeleting={isDeleting}
+ />
+
+ );
+}
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts
index 3d97eae..0f2fb79 100644
--- a/apps/web/src/lib/api/index.ts
+++ b/apps/web/src/lib/api/index.ts
@@ -14,3 +14,4 @@ export * from "./teams";
export * from "./personalities";
export * from "./telemetry";
export * from "./dashboard";
+export * from "./projects";
diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts
new file mode 100644
index 0000000..89a11ca
--- /dev/null
+++ b/apps/web/src/lib/api/projects.ts
@@ -0,0 +1,104 @@
+/**
+ * Projects API Client
+ * Handles project-related API requests
+ */
+
+import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
+
+/**
+ * Project status enum (matches backend ProjectStatus)
+ */
+export enum ProjectStatus {
+ PLANNING = "PLANNING",
+ ACTIVE = "ACTIVE",
+ PAUSED = "PAUSED",
+ COMPLETED = "COMPLETED",
+ ARCHIVED = "ARCHIVED",
+}
+
+/**
+ * Project response interface (matches Prisma Project model)
+ */
+export interface Project {
+ id: string;
+ workspaceId: string;
+ name: string;
+ description: string | null;
+ status: ProjectStatus;
+ startDate: string | null;
+ endDate: string | null;
+ creatorId: string;
+ domainId: string | null;
+ color: string | null;
+ metadata: Record;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/**
+ * DTO for creating a new project
+ */
+export interface CreateProjectDto {
+ name: string;
+ description?: string;
+ status?: ProjectStatus;
+ startDate?: string;
+ endDate?: string;
+ color?: string;
+ metadata?: Record;
+}
+
+/**
+ * DTO for updating an existing project
+ */
+export interface UpdateProjectDto {
+ name?: string;
+ description?: string | null;
+ status?: ProjectStatus;
+ startDate?: string | null;
+ endDate?: string | null;
+ color?: string | null;
+ metadata?: Record;
+}
+
+/**
+ * Fetch all projects for a workspace
+ */
+export async function fetchProjects(workspaceId?: string): Promise {
+ return apiGet("/api/projects", workspaceId);
+}
+
+/**
+ * Fetch a single project by ID
+ */
+export async function fetchProject(id: string, workspaceId?: string): Promise {
+ return apiGet(`/api/projects/${id}`, workspaceId);
+}
+
+/**
+ * Create a new project
+ */
+export async function createProject(
+ data: CreateProjectDto,
+ workspaceId?: string
+): Promise {
+ return apiPost("/api/projects", data, workspaceId);
+}
+
+/**
+ * Update an existing project
+ */
+export async function updateProject(
+ id: string,
+ data: UpdateProjectDto,
+ workspaceId?: string
+): Promise {
+ return apiPatch(`/api/projects/${id}`, data, workspaceId);
+}
+
+/**
+ * Delete a project
+ */
+export async function deleteProject(id: string, workspaceId?: string): Promise {
+ await apiDelete>(`/api/projects/${id}`, workspaceId);
+}