Repository Architecture
# Polar Edge — Application Architecture This document describes the structural conventions used across the Polar Edge monorepo. Follow these patterns when adding features, refactoring, or creating new apps. --- ## Table of Contents 1. [Monorepo Layout](#monorepo-layout) 2. [Feature-First Architecture](#feature-first-architecture) 3. [App Router Conventions](#app-router-conventions) 4. [Feature Module Anatomy](#feature-module-anatomy) 5. [Server Actions Pattern](#server-actions-pattern) 6. [Data Queries & Caching](#data-queries--caching) 7. [RSC Boundaries — Server vs Client](#rsc-boundaries--server-vs-client) 8. [Centralized Routes](#centralized-routes) 9. [Parallel Routes (Conditional UI)](#parallel-routes-conditional-ui) 10. [Shared UI Components](#shared-ui-components) 11. [Lib Directory Rules](#lib-directory-rules) 12. [Where Does My Code Go?](#where-does-my-code-go) --- ## Monorepo Layout The repository is a [Turborepo](https://turbo.build) monorepo managed with [pnpm workspaces](https://pnpm.io/workspaces). ```text polar-edge/ ├── apps/ │ ├── scouting/ # Main scouting app (Next.js 16 + Turbopack) │ ├── basecamp/ # Backend service (NestJS, Discord bot, Sheets) │ └── basecamp-fe/ # Frontend for Basecamp features (Next.js) ├── packages/ │ ├── ui/ # Shared Radix UI component library │ ├── tba-sdk/ # The Blue Alliance API TypeScript client │ ├── ai/ # Shared AI/LLM utilities │ ├── twofa/ # Two-factor authentication utilities │ ├── typescript-config/ │ └── vitest-config/ └── turbo.json ``` **Key rules:** - Business logic lives in `apps/` — packages are pure utilities with no app-specific logic. - Shared packages are consumed as workspace dependencies (`@repo/ui`, `@repo/tba-sdk`, etc.). - Run tasks through Turbo: `turbo build`, `turbo dev --filter=scouting`, etc. Don't run app scripts directly unless you have a reason. --- ## Feature-First Architecture Both Next.js apps (`scouting` and `basecamp-fe`) use a **feature-first** layout. The `src/app/` directory is intentionally thin — it contains only Next.js routing files. All business logic, data access, and feature-specific components live in `src/features/`. ```text src/ ├── app/ ← Routing only. Pages delegate to features. ├── features/ ← Primary home for all business logic. │ └── / │ ├── actions.ts # Server actions │ ├── queries.ts # Database reads │ ├── types.ts # Feature-specific types │ ├── components/ # Feature-specific React components │ ├── hooks/ # Feature-specific client hooks │ └── contexts/ # Feature-specific React contexts ├── components/ ← Shared UI used across multiple features (nav, layout, etc.) └── lib/ ← Pure utilities and library config. No feature logic. ``` The benefit: when you need to find or change anything about the "picklist" feature, you go to `src/features/picklist/`. You don't have to hunt across `src/app/`, `src/utils/`, `src/api/`, etc. --- ## App Router Conventions ### Pages are thin Pages (`page.tsx`) should authenticate, load data, and compose feature components. They should not contain business logic themselves. ```tsx // ✅ Good — app/admin/members/page.tsx export default function MembersPage() { return (
); } ``` The data-fetching and rendering logic lives in `MembersContent`, which is an async RSC imported from `src/features/org/members/`. ### Async Server Components for data Prefer async RSCs for data fetching. Place the async component in `features/` and import it into the page: ```tsx // features/org/members/components/MembersContent.tsx async function MembersContent() { const activeMember = await requireAdminMember(); const members = await fetchMembers(activeMember.organizationId); return ; } ``` ### Suspense boundaries Wrap async data-fetching components in `` in the page. Provide a meaningful skeleton as the fallback — not just a spinner. This enables streaming and prevents the whole page from blocking. ```tsx }> ``` --- ## Feature Module Anatomy Each feature in `src/features//` follows this file structure. Not every file is required — only create what the feature needs: | File / Directory | Purpose | | --- | --- | | `actions.ts` | Next.js Server Actions (`"use server"`). Mutations only — auth, validate, write DB. | | `queries.ts` | Database reads. Always `import "server-only"`. Use `"use cache"` for expensive reads. | | `types.ts` | TypeScript types and interfaces specific to this feature. | | `components/` | React components used only by this feature. | | `hooks/` | Client-side React hooks used only by this feature. | | `contexts/` | React context providers used only by this feature. | | `*.test.ts` | Tests co-located with the code they test. | **Example — `picklist` feature:** ```text src/features/picklist/ ├── actions.ts # createPicklist, deletePicklist, reorderTeams, etc. ├── queries.ts # getPicklistsForOrganization, etc. ├── types.ts # PicklistTeam, PicklistWithTeams, etc. └── components/ ├── CreatePicklistDialog.tsx ├── PicklistTeamsTable.tsx ├── DeletePicklistButton.tsx └── ... ``` **Example — nested features (`org`):** When a domain has sub-features with distinct concerns, nest them: ```text src/features/org/ ├── settings/ │ ├── actions.ts │ └── components/OrganizationSettingsForm.tsx ├── members/ │ ├── actions.ts │ └── components/ │ ├── RemoveMemberButton.tsx │ └── RoleSelect.tsx └── invites/ ├── actions.ts └── components/InviteLinkManager.tsx ``` --- ## Server Actions Pattern Server actions live in `features//actions.ts` and always follow this order: ```typescript "use server"; export async function exampleAction(input: unknown) { // 1. Authenticate — get session const session = await auth.api.getSession({ headers: await headers() }); if (!session) return { error: "Unauthorized" }; // 2. Authorize — get active org member (role check if needed) const member = await auth.api.getActiveMember({ headers: await headers() }); if (!member) return { error: "No active organization" }; // 3. Context — get active event if relevant const event = await getActiveEventForOrganization(member.organizationId); if (!event) return { error: "No active event" }; // 4. Validate input const validated = schema.safeParse(input); if (!validated.success) return { error: "Invalid input" }; // 5. Write — use a transaction for multi-table operations const result = await db.transaction(async (tx) => { // ... perform operations return data; }); // 6. Return success or error (never throw to the client) return { success: true, data: result }; } ``` **Rules:** - Always return `{ error: string }` or `{ success: true, ... }` — never throw. - Use `db.transaction()` when writing to multiple tables atomically. - Validate every external input with Zod before touching the database. - Do not perform mutations from `queries.ts` — keep reads and writes separate. --- ## Data Queries & Caching Database reads live in `features//queries.ts`. Always mark this file as server-only: ```typescript import "server-only"; ``` This prevents accidental import in client components and gives a clear build-time error if it happens. ### Use `"use cache"` for expensive reads For reads that are called frequently or are expensive, use Next.js's `"use cache"` directive with `cacheLife` and `cacheTag`: ```typescript import "server-only"; import { cacheLife, cacheTag } from "next/cache"; import { cacheTags } from "@/lib/cache"; export async function getStandFormCounts( organizationId: string, eventId: string | null ) { "use cache"; cacheLife("hours"); cacheTag(cacheTags.leaderboardStand(organizationId)); return db .select({ ... }) .from(standForm) .where(...); } ``` ### Cache tag centralization All cache tag strings are defined in `src/lib/cache.ts`. Never hardcode tag strings at the call site — always use the `cacheTags` object: ```typescript // src/lib/cache.ts export const cacheTags = { leaderboardStand: (organizationId: string) => `leaderboard-stand-${organizationId}`, teamMetrics: (eventId: string) => `team-metrics-${eventId}`, // ... }; ``` When a mutation invalidates cached data, call `revalidateTag(cacheTags.leaderboardStand(orgId))` in the relevant server action. ### Parallel data fetching When a page or component needs multiple independent pieces of data, fetch them in parallel with `Promise.all`: ```typescript const [organization, members] = await Promise.all([ auth.api.getFullOrganization({ ... }), auth.api.listMembers({ ... }), ]); ``` --- ## RSC Boundaries — Server vs Client Next.js components are **Server Components by default**. Only add `"use client"` when a component needs browser APIs, state, or event handlers. ### Server Components (default) - Fetch data directly (async/await) - Access cookies, headers, environment variables - Import server-only code (DB, auth, etc.) - Cannot use `useState`, `useEffect`, or browser APIs ### Client Components (`"use client"`) - Interactive UI — event handlers, state, effects - Browser APIs (`window`, `localStorage`, timers) - Third-party client libraries - React context providers that hold state ### Minimize the client boundary Push `"use client"` as deep into the component tree as possible. A good pattern: keep the page and data-fetching wrapper as RSCs, and only mark leaf components (buttons, forms, interactive widgets) as client: ```tsx // ✅ Good — page stays server, only the interactive button is client // app/picklist/[id]/page.tsx (RSC) export default async function PicklistPage({ params }) { const picklist = await getPicklist(params.id); // server query return ; } // features/picklist/components/DeletePicklistButton.tsx "use client"; export function DeletePicklistButton({ id }) { ... } ``` ### Context providers Context providers must be client components (they use React state internally). The pattern: create a `` client component that wraps its children, and mount it as high as needed in the tree without making parent RSCs into client components. ```tsx // features/totp/contexts/TOTPContext.tsx "use client"; export function TOTPProvider({ secret, children }) { ... } // app/@auth/page.tsx (RSC — just renders the provider) export default async function AuthPage() { const secret = (await cookies()).get("toofaSecret")?.value; return ( ); } ``` --- ## Centralized Routes All route paths are defined in `src/lib/routes.ts`. Never hardcode route strings in components or actions — always import from `routes`. ```typescript // src/lib/routes.ts export const routes = { home: "/", admin: { root: "/admin", members: "/admin/members", invites: "/admin/invites", event: "/admin/event", settings: "/admin/settings", }, analysis: { team: (teamNumber: number | string) => `/analysis/teams/${teamNumber}` as const, }, // ... } as const; ``` **Usage:** ```tsx import { routes } from "@/lib/routes"; Members Team 1234 ``` **Why:** Refactoring a URL means changing one place. TypeScript catches broken references at build time. --- ## Parallel Routes (Conditional UI) When a page needs to show fundamentally different UI based on auth state (or another condition), use Next.js [parallel routes](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes) rather than conditional rendering in a single component. The `basecamp-fe` app uses this for its auth gate: ```text app/ ├── layout.tsx # Renders both slots: {props.auth} and {props.unauth} ├── @auth/ │ └── page.tsx # Shown when authenticated └── @unauth/ └── page.tsx # Shown when unauthenticated ``` ```tsx // app/layout.tsx export default function RootLayout(props: LayoutProps<"/">) { return ( {props.unauth} {/* renders @unauth/page.tsx */} {props.auth} {/* renders @auth/page.tsx */} ); } ``` Each slot independently validates auth state and returns `null` if not applicable: ```tsx // app/@auth/page.tsx export default async function AuthPage() { const token = (await cookies()).get("toofaToken")?.value; if (!token || !(await validateToken(token))) return null; // render authenticated UI } // app/@unauth/page.tsx export default async function UnauthorizedPage() { const token = (await cookies()).get("toofaToken")?.value; if (token && (await validateToken(token))) return null; return ; } ``` **When to use parallel routes:** - Auth gates where the two states are visually distinct pages (not just a hidden button) - Dashboard layouts with independently loading panels - Modal overlays with intercepting routes **When NOT to use them:** Simple show/hide toggles — use conditional rendering in a single component. --- ## Shared UI Components ### `@repo/ui` — cross-app design system Radix UI primitives with Tailwind v4 styling. Import from the package path: ```typescript import { Button } from "@repo/ui/components/button"; import { Dialog, DialogContent, DialogTrigger } from "@repo/ui/components/dialog"; import { Table, TableBody, TableCell, TableHead } from "@repo/ui/components/table"; ``` Use `@repo/ui` components as building blocks. Don't recreate primitives (buttons, inputs, dialogs) from scratch. ### `src/components/` — cross-feature app components Components that are used by multiple features within the same app but aren't generic enough for `@repo/ui`: - Navigation (`Navbar`, `Sidebar`) - Layout wrappers - App-wide indicators (offline status, toasts) **Rule:** If a component is only used by one feature, it belongs in `src/features//components/`, not `src/components/`. --- ## Lib Directory Rules `src/lib/` is for pure utilities and library configuration. It has no feature logic. | Path | Contents | | --- | --- | | `lib/auth.ts` | Better Auth configuration | | `lib/auth-client.ts` | Client-side auth helpers | | `lib/permissions.ts` | Super admin checks | | `lib/routes.ts` | Centralized route definitions | | `lib/cache.ts` | Cache tag constants | | `lib/utils.ts` | Generic utility functions (`cn`, etc.) | | `lib/compress-image.ts` | Image compression utility | | `lib/database/` | Drizzle client, schema tables, relations, views, types | | `lib/server/` | Server-side helpers: auth guards, org helpers, storage, TBA, tokens | | `lib/offline/` | Network status, offline queue context, toasts | **What does NOT go in `lib/`:** - Feature-specific actions, queries, or components - Anything that imports from `features/` --- ## Where Does My Code Go? Use this decision tree when adding new code: ```txt Is it a Next.js routing file (page, layout, loading, error)? └─ Yes → src/app// Is it a database read? └─ Yes → src/features//queries.ts (with "server-only" import) Is it a mutation / form submission handler? └─ Yes → src/features//actions.ts (with "use server") Is it a React component used only by one feature? └─ Yes → src/features//components/ Is it a React component used by multiple features? └─ Yes → src/components/ (if app-specific) or packages/ui (if cross-app) Is it a React hook used only by one feature? └─ Yes → src/features//hooks/ Is it a React context / provider for one feature? └─ Yes → src/features//contexts/ Is it a route path string? └─ Yes → src/lib/routes.ts Is it a cache tag string? └─ Yes → src/lib/cache.ts Is it a shared type for one feature? └─ Yes → src/features//types.ts Is it a generic utility with no feature dependency? └─ Yes → src/lib/utils.ts (or a new focused file in src/lib/) ``` --- ## Quick Reference | Convention | Rule | | --- | --- | | Default RSC | All components are server by default; `"use client"` only when needed | | Data fetching | Async RSC + `queries.ts` with `"use cache"` for expensive reads | | Mutations | Server Actions in `actions.ts`; always auth → validate → transact → return | | Routes | All paths in `src/lib/routes.ts`; never hardcode strings | | Cache tags | All tags in `src/lib/cache.ts` via `cacheTags` object | | Feature isolation | Each feature is self-contained; no cross-feature imports | | Client boundary | Push `"use client"` as deep as possible | | `server-only` | All `queries.ts` files start with `import "server-only"` | | UI primitives | Use `@repo/ui` — don't reinvent buttons, dialogs, tables | | Suspense | Wrap async RSCs in `` with skeleton fallbacks |