This document serves as the source of truth for architectural decisions, coding standards, and patterns for this project. All contributors (human and AI) should refer to this file before making significant changes.
- Framework: Next.js 16+ (App Router)
- Language: TypeScript
- API: tRPC (Type-safe API layer)
- Database: PostgreSQL on Neon.tech (via Prisma ORM)
- Styling: Tailwind CSS v4
- Use
clsxandtailwind-merge(viacnutility) for dynamic classes. - Avoid CSS Modules or styled-components unless absolutely necessary.
- Use
- Icons:
lucide-react - Data Fetching: TanStack Query (via tRPC)
We follow a Feature-Based Architecture.
src/features/: Contains self-contained features (e.g.,ticket-widget).- Each feature should have its own components, hooks, schemas, and types.
src/components/ui/: Reusable, generic UI components (shadcn/ui style).src/app/: Next.js pages/routes ONLY. Logic should be delegated to feature components.src/lib/: Shared utilities and global configuration.
All forms MUST follow this pattern:
- Library: Use
react-hook-formfor form state management. - Validation: Use
zodfor schema validation with@hookform/resolvers/zod. - Schema Isolation:
- DO NOT define Zod schemas inside component files.
- DO define schemas in a separate file (e.g.,
schemas.ts,[feature].schema.ts). - DO export the inferred TypeScript type from the schema file.
Example: src/features/auth/schemas.ts
import { z } from "zod";
export const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export type LoginDto = z.infer<typeof LoginSchema>;src/features/auth/login-form.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema, type LoginDto } from "./schemas";
export function LoginForm() {
const form = useForm<LoginDto>({
resolver: zodResolver(LoginSchema),
});
// ...
}- Single Source of Truth: Types for entities and DTOs (Data Transfer Objects) should be primarily defined via Zod schemas where possible to ensure runtime/compile-time parity.
- Naming:
- Schemas:
[Entity]Schema(e.g.,UserSchema) - Types:
[Entity](e.g.,User) - DTOs:
Create[Entity]Dto,Update[Entity]Dto,Search[Entity]Dto
- Schemas:
- Location:
- Feature-Specific: Store in
schemas.tsortypes.tswithin the relevant feature folder (e.g.src/features/auth/schemas.ts). - Shared/Global: Store in
src/lib/schemas/(e.g.src/lib/schemas/project.ts).
- Feature-Specific: Store in
- Server State (Async): TanStack Query (via tRPC). Use for all data fetching.
- Local Component State (Ephemeral):
useState/useReducer. Use for form inputs, toggle states. - Global UI State (Shared): Zustand. Use for high-frequency updates (e.g.
sidebarOpen,selectedElementId).- Why? Avoids the "Context Hell" re-render performance penalty.
- Complex Logic/Flows (Critical): XState. Use for the Editor Engine core (e.g. Drag-n-Drop finite states).
- Why? mathematically impossible to enter invalid states (e.g. "Select" while "Dragging").
- Logic: Handled by XState (The Brain).
- Sync: XState actions update the Zustand store (The Memory).
- View: Components read from Zustand via selectors.
-
tRPC Routers (
src/server/api/routers)- Role: The "Service Layer" & "Controller" combined.
- Rule: All frontend-backend communication happens here.
- Logic: Validate input (Zod), call Database/External APIs, return typed data.
-
Prisma (
src/server/db.ts)- Role: Database ORM.
- Rule: Use
ctx.dbin tRPC procedures to access data.
-
External Services
- Role: Logic for 3rd party APIs (e.g. Trello).
- Rule: Can be inline in Router if simple, or separated into
src/server/servicesif complex.
To ensure this codebase remains maintainable by both humans and AI agents, strict documentation rules apply to Public Boundaries:
-
Explicit Interfaces:
- All Public Functions (exported from
src/lib,src/hooks, etc.) MUST have explicit TypeScript return types. relying on inference for public APIs is forbidden. - API Routers: Inputs must be defined via Zod.
- All Public Functions (exported from
-
JSDoc is Mandatory for Complexity:
- Any non-trivial function or component must have JSDoc explaining Intent vs Implementation.
- Format:
/** * Calculates X based on Y. * @param someInput - Explanation of constraints (e.g. "must be positive") * @returns The calculated value in [unit] */ export const calculateX = (someInput: number): number => { ... }
Rule: Use Discriminated Unions for all complex UI states.
Why: Prevents "impossible states" (e.g. isLoading: true but error is present) and catches typo-based bugs at compile time.
Bad:
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">(
"idle",
);Good:
type WidgetState =
| { status: "IDLE" }
| { status: "LOADING"; progress?: number }
| { status: "SUCCESS"; data: TicketData }
| { status: "ERROR"; error: Error };We follow a Three-Tier strategy for consistency and security:
-
API Level (Backend):
- Library:
TRPCError. - Rule: ALL backend errors must be caught and re-thrown as
TRPCError. - Security: Never leak stack traces or raw database errors to the client.
- Pattern:
throw new TRPCError({ code: "NOT_FOUND", message: "User friendly message", });
- Library:
-
UI Level (Frontend):
- Library:
react-hot-toast(Global Toaster). - Rule: Use
onErrorcallbacks in tRPC mutations to trigger toast notifications. - Forms: Zod validation errors should be displayed inline via
react-hook-form.
- Library:
-
Logging Level (Developer Observability):
- Library:
console.error(Dev) /Sentry(Prod). - Rule: Log full stack traces on the server before throwing the sanitized
TRPCError.
- Library:
Decision: We implement strict Zod schemas for all data structures (forms, API inputs, entities). Reasoning: ensures type safety and runtime validation are always in sync. Reduces bugs caused by mismatched types.
Decision: Code is organized by "feature" (src/features/) rather than by technical type (e.g. src/components, src/hooks).
Reasoning: Keeps related code together, making it easier to delete or refactor entire features without hunting through the codebase.
Decision: Use Tailwind v4 alpha/beta features. Reasoning: Performance improvements and simplified configuration.
Decision: All file operations for the Editor Runtime must go through an IFileSystem interface.
Reasoning: Decouples the Editor Engine (which runs in-browser) from the physical storage (Database/S3). Enables features like "Preview Mode" and "Undo/Redo" in memory without disk writes.
Decision: All state-mutating actions in the editor (e.g., changing styles, moving elements) must be encapsulated as reversible "Commands". Reasoning: Enables Undo/Redo functionality and facilitates future multiplayer collaboration (syncing commands instead of state).
Decision: Use TanStack Query for all server-state management.
Reasoning: Replaces manual useEffect and useState for fetching. Provides built-in caching, deduping, and global error handling.
Decision: UI components must implement the asChild Slot pattern (via Radix UI) for composition.
Reasoning: Decouples component look-and-feel from the rendered HTML tag (e.g. rendering a Button style as an <a> tag), avoiding hydration errors and improving accessibility.
Context: We deploy to Vercel Serverless. This introduces specific constraints that affect architectural decisions:
- Ephemeral Filesystem: We cannot save user uploads or generated sites to disk. (Solved by:
ADR-004 VFS+ PostgreSQL/S3). - Request Body Limit (4.5MB): Serverless functions reject large payloads.
- Impact: We cannot send videos/large images via tRPC JSON.
- Solution: Use
src/app/api/upload(Route Handler) for streaming uploads or Client-to-S3 direct uploads.
- Execution Timeout (10s-60s): Long-running tasks (e.g. "Export Site") must be async/queued, not synchronous HTTP requests.
- Connection Limits: Serverless functions can exhaust DB connections. (Solved by: Prisma + Connection Pooling/PgBouncer).
Decision: Instead of saving full file content, the Editor sends granular "Patches" (AST Operations) to the server. Mechanism:
- Auto-Tagging: On boot, the Engine scans source files (AST for Next.js, DOM for Static) and injects stable
data-lid="uuid"strings. - Patching: The Editor targets these IDs. - Next.js: Uses
ts-morphto update JSX attributes/text inpage.tsx. - Static: Usescheerioto update HTML tags inindex.html. Reasoning: Preserves formatting, comments, and imports in complex frameworks like Next.js. Prevents data loss from race conditions.
Decision: We use two distinct runtime strategies based on project type.
- Next.js Projects: Spawn a child process (
next dev) on a random port. The URL ishttp://localhost:[port].- Why: Requires Node.js runtime for SSR/API routes.
- Static Projects: Served via an Internal API Proxy (
/api/projects/[id/assets).- Why: Zero-overhead. No spawn required. Scalable to thousands of projects.
- Rewrites: Configured via
next.config.jsto expose cleaner URLs (/site/[id]/...).
Decision: The Editor accumulates patches in a generic queue and compacts them before sending.
Logic: If multiple patches target the same lid and property (e.g. typing text "abc"), only the final state is sent after a 2s debounce.
Reasoning: Reduces server load by 90% during active editing sessions without sacrificing data integrity.