Repository Architecture This document describes the structural conventions used across the Polar Edge monorepo. Follow these patterns when adding features, refactoring, or creating new apps. Monorepo Layout The repository is a Turborepo monorepo managed with pnpm workspaces . 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/ . 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. // ✅ Good — app/admin/members/page.tsx export default function MembersPage() { return (
Team Members }> {/* async RSC from features/ */}
); } 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: // 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. }> 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: 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: 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: "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: 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 : 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: // 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 : 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: // ✅ 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. // 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 . // 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: 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 rather than conditional rendering in a single component. The basecamp-fe app uses this for its auth gate: app/ ├── layout.tsx # Renders both slots: {props.auth} and {props.unauth} ├── @auth/ │ └── page.tsx # Shown when authenticated └── @unauth/ └── page.tsx # Shown when unauthenticated // 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: // 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: 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: 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