Imported from ngothithanhh/quizzlet-clone (
quizlet-fe/AGENTS.md). Install upstream withnpx skills add ngothithanhh/quizzlet-clone --skill quizlet-fe. Copyright stays with the author.
AGENTS Guide
AI agents should internalize these patterns before making changes to this codebase. This monorepo uses Turborepo + PNPM with a Turbo-driven TypeScript + React stack across web (Next.js), mobile (Expo), and shared backend packages.
Monorepo Structure
Workspace Layout (Turborepo + PNPM)
apps/ → User-facing applications
nextjs/ → Web frontend (Next.js 14, React 18)
expo/ → Mobile frontend (React Native)
auth-proxy/ → OAuth redirect handler for preview/Expo
packages/ → Shared domain packages (internal @acme/* namespace)
api/ → tRPC routers & RPC definitions
auth/ → NextAuth config, session/token helpers
db/ → Drizzle ORM client, schema, queries, mutations
ui/ → Shared React components
validators/ → Zod schemas for API validation
tooling/ → Shared dev config
eslint/ → Linting rules (enforces env module access)
prettier/ → Code formatting config
tailwind/ → Tailwind presets (web + native)
typescript/ → TypeScript base config
Key constraint: apps/nextjs/next.config.js transpiles @acme/* packages directly—do NOT assume prebuilt package output during dev.
Critical Data Flow
Request Path: Web + Mobile → Single tRPC API Surface
- Endpoint:
apps/nextjs/src/app/api/trpc/[trpc]/route.ts - Router:
packages/api/src/root.tsexportsappRouterwith 7 sub-routers:auth,studySet,user,folder,flashcard,starredFlashcard,activity
- Context:
packages/api/src/trpc.ts- isomorphic auth logic:- Next.js: Reads cookie session via
auth()(NextAuth) - Expo: Reads
Authorization: Bearer <token>header + validates viavalidateToken()
- Next.js: Reads cookie session via
- Database: All queries/mutations centralized in
@acme/db/src/{queries,mutations}/index.ts; routers compose these.
Example Flow: Creating a StudySet
- Client calls
api.studySet.create()with Zod-validatedStudySetSchema - tRPC routes to
protectedProcedureinpackages/api/src/router/studySet.ts - Creates transaction via
ctx.db.transaction() - Calls
createStudySet()from@acme/db/mutations - Calls
upsertFlashcards()from same mutations module - Returns new entity; client reactively updates via React Query
Auth Roundtrips (Platform-Specific)
- Next.js: Cookie-based session (standard NextAuth flow)
- Expo:
- Custom redirect state cookie:
__acme-expo-redirect-state - Deep link contains
session_tokenparam - Handled in
apps/expo/src/utils/auth.ts+apps/nextjs/src/app/api/auth/[...nextauth]/route.ts
- Custom redirect state cookie:
Critical Workflows
Dev Toolchain
- Node: ≥22.10.0 (check
.nvmrc) - PNPM: 9.15.4+
- Commands:
pnpm install # Install deps + lint workspace (postinstall hook runs) pnpm dev # All dev servers (turbo watch) pnpm dev:next # Only Next.js graph (faster loop) pnpm db:push # Drizzle schema migration pnpm db:studio # Web UI for local DB pnpm lint # ESLint (enforces env access rules) pnpm typecheck # TypeScript check pnpm build # Turborepo build (topo order) pnpm format:fix # Prettier fix pnpm lint:ws # Workspace linting (dependency rules) - No test scripts: Safety relies on lint + typecheck + build (no Jest/Vitest configured).
Database Infrastructure
- Local DB: Docker (PostgreSQL) + local Neon HTTP proxy in
packages/db/docker-compose.yml - Migrations: Drizzle—edit schema in
packages/db/src/schema/*.ts, thenpnpm db:push - Client:
@acme/db/src/client.tsinitializes Drizzle pool; usessnake_casecasing for DB columns
Build Task Parallelization (Turbo)
- Tasks in
turbo.jsondefine dependencies:devdepends on^dev(packages first) lintdepends on^topo+^build(ensures types resolve)- UI mode shows task progress; logs auto-filter to new output
Project-Specific Conventions
Environment Variables (Strict Access Rules)
Never call process.env directly. ESLint enforces this via no-restricted-properties in tooling/eslint/base.js:
- Next.js: Import
envfromapps/nextjs/src/env.ts(extends auth env) - Auth: Env contract lives in
packages/auth/env.ts(Google OAuth, GitHub OAuth, email, JWT secret) - Next.js extends auth: Adds
POSTGRES_URL(server) + S3 client vars - Skip validation for linting:
skipValidation: !!process.env.CI || process.env.npm_lifecycle_event === 'lint'
Required env vars for dev:
AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET # OAuth providers
AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
AUTH_EMAIL_FROM # Email sender (OTP)
AUTH_SECRET # NextAuth secret (auto-gen in dev, required in prod)
POSTGRES_URL # DB connection (local Docker or Neon.tech)
NEXT_PUBLIC_S3_* # S3 client credentials (Expo file uploads)
tRPC Procedures: Auth Split
publicProcedure: Unauthenticated access (optionalctx.session)protectedProcedure: Requires session; throwsUNAUTHORIZEDif missing- Both include
timingMiddleware(artificial 100-500ms delay in dev to simulate network latency)
Example (from studySet.ts):
export const studySetRouter = {
popular: publicProcedure.query(async ({ ctx }) =>
await getPopularStudySetsQuery(ctx.db)
),
create: protectedProcedure
.input(StudySetSchema)
.mutation(async ({ input, ctx }) => {
// Uses ctx.session.user.id for ownership
}),
}
Validation: Shared Zod Schemas
- All API input validation uses schemas from
@acme/validators - Example:
StudySetSchemainpackages/api/src/router/studySet.ts - Error handling via
zodErrorin tRPC'serrorFormatter(flattens nested errors for client)
Code Organization: Separation of Concerns
- DB queries:
packages/db/src/queries/index.ts(read-only, no side effects) - DB mutations:
packages/db/src/mutations/index.ts(writes + transactions) - Routers: Compose queries/mutations; do NOT embed SQL blocks
- DTOs: Located in each app (Next.js/Expo); mapper pattern reduces boilerplate
TypeScript Paths
- Next.js uses
~/*→apps/nextjs/src/*(configured intsconfig.json) - Packages use relative imports or absolute
@acme/*namespace
Formatting & Linting
- Prettier: Centralized config at
tooling/prettier/index.js - Import order: Enforced by
importPluginin ESLint - Run via
pnpm format:fixandpnpm lint:fix(not per-editor custom formatters)
Integration Guardrails
When Modifying Auth/Sessions
- Test both Next.js cookie flow (sign in via web) and Expo bearer-token flow (via auth util)
- Verify
packages/api/src/trpc.tsisomorphic getter handles bothAuthorizationheader + cookies - Check Expo redirect state cookie handling in
apps/expo/src/utils/auth.ts
When Adding API Routers
- Create router in
packages/api/src/router/{feature}.tsusingpublicProcedureorprotectedProcedure - Export as object satisfying
TRPCRouterRecord - Wire into
appRouterinpackages/api/src/root.ts - Client consumption:
- Next.js: Via
apps/nextjs/src/trpc/react.tsxhooks (e.g.,api.studySet.create.useMutation()) - Expo: Via
apps/expo/src/utils/api.tsx(same API)
- Next.js: Via
When Modifying DB Schema
- Edit schema files in
packages/db/src/schema/(e.g.,studySet.ts) - Run
pnpm db:push(Drizzle automatic migration) - Update or create query/mutation helpers in
packages/db/src/{queries,mutations}/index.ts - Update affected tRPC routers to use new helpers
- Test via
pnpm typecheck+pnpm lint
When Adding Shared UI Components
- Build in
packages/ui/src/(e.g.,button.tsx) - Use shadcn/ui CLI for UI components:
pnpm ui-add(runspackages/uiscript) - Export from
packages/uibarrel file - Import in apps via
@acme/ui - Avoid app-local duplication
Common Pitfalls to Avoid
- ❌ Direct
process.envaccess → Useenvmodule imports - ❌ SQL blocks in routers → Extract to
@acme/db/mutationsorqueries - ❌ Missing Zod validation → All API inputs must have
.input(ZodSchema) - ❌ DB N+1 queries → Use Drizzle
leftJoinor relation query helpers - ❌ Hardcoded string IDs → Use
crypto.randomUUID()or Drizzle-generated defaults - ❌ Forgetting transactions → Wrap multi-step DB ops in
ctx.db.transaction()
Example: Adding a New Feature
Scenario: Add a "quiz results" tracker.
-
Add DB schema:
// packages/db/src/schema/quizResult.ts export const QuizResult = pgTable("quiz_result", { id: uuid().default(sql`gen_random_uuid()`).notNull().primaryKey(), userId: uuid().notNull(), studySetId: uuid().notNull(), score: integer().notNull(), // ... timestamps, relations }); -
Create mutations & queries:
// packages/db/src/mutations/index.ts export const createQuizResult = async (db: Database, input: NewQuizResult) => { return await db.insert(QuizResult).values(input).returning(); }; // packages/db/src/queries/index.ts export const getUserQuizResults = async (db: Database, userId: string) => { return await db.query.QuizResult.findMany({ where: eq(QuizResult.userId, userId) }); }; -
Wire tRPC router:
// packages/api/src/router/quizResult.ts import { createQuizResult, getUserQuizResults } from "@acme/db/mutations"; import { getUserQuizResultsQuery } from "@acme/db/queries"; export const quizResultRouter = { create: protectedProcedure .input(z.object({ studySetId: z.string(), score: z.number() })) .mutation(async ({ input, ctx }) => { return await createQuizResult(ctx.db, { userId: ctx.session.user.id, studySetId: input.studySetId, score: input.score, }); }), userResults: protectedProcedure.query(async ({ ctx }) => { return await getUserQuizResults(ctx.db, ctx.session.user.id); }), } satisfies TRPCRouterRecord; -
Register in root router:
// packages/api/src/root.ts export const appRouter = createTRPCRouter({ // ... existing quizResult: quizResultRouter, }); -
Use in frontend:
// apps/nextjs/src/components/QuizResults.tsx import { api } from "~/trpc/react"; export function QuizResults() { const { data: results } = api.quizResult.userResults.useQuery(); const createResult = api.quizResult.create.useMutation(); // ... } -
Test locally:
pnpm db:push # Apply schema pnpm typecheck # Verify types pnpm lint # Check imports pnpm dev:next # Test in browser