This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
Read this file before writing any code. It will save reprompts.
This is mandatory. Every async operation must have all three states handled. Never leave the user staring at a button with no indication of what is happening.
<Toaster> is already mounted in app/layout.tsx. Import and use directly:
import { toast } from "sonner";
// Loading state for long operations
const id = toast.loading("Saving...", { duration: Infinity });
// Then resolve it:
toast.success("Saved", { id });
toast.error("Failed to save", { id, description: err.message });
// Short operations
toast.error("Failed to generate flashcards", {
description: err instanceof Error ? err.message : undefined,
duration: 5000,
});
toast.success("Deck saved");Use toast.loading + id update for anything that takes more than ~1 second. Use toast.error / toast.success directly for quick ops.
Auth and settings forms use inline error / message state rendered as colored boxes with aria-live="polite". Use this pattern only for forms where the error is tied to specific fields and the user needs to act before proceeding — not for async API results.
Use Loader2 from lucide-react with animate-spin on button icons during async operations. Disable the button and swap its label while loading:
<Button disabled={isSaving} leadingIcon={isSaving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}>
{isSaving ? "Saving..." : "Save"}
</Button>- Do not render raw API error responses or Zod issue arrays in the UI. In Zod v4,
ZodError.messageis a JSON-serialized issues array — catch it server-side, log it, and throw a plain string instead. - Do not silently swallow errors. Every catch block must either call
toast.error(...)or set visible error state. - Do not skip loading state. Buttons must be
disabledand show a spinner while their action is in flight.
API routes must console.error(...) the actual error for server logs, then return only a short user-facing string:
} catch (error) {
console.error("[POST /api/example]", error);
return NextResponse.json({ error: "Operation failed" }, { status: 500 });
}Never return error.message directly from a catch block — it may contain Zod issue JSON, stack traces, or DB driver internals.
The app shell is h-screen overflow-hidden. Content must scroll inside panels, not on the page body. Getting this wrong means content is clipped with no scrollbar.
app-shell: h-screen overflow-hidden
└── sidebar
└── main: h-full w-full (overflow-hidden for most routes)
└── page root: h-full min-h-0 flex flex-col overflow-hidden
└── header: shrink-0
└── scrollable region: flex-1 min-h-0 overflow-y-auto
min-h-0 is required on every flex child that should shrink below its content size. Without it, flex items refuse to shrink and content overflows the viewport invisibly.
// Page root
<main className="flex h-full min-h-0 flex-col gap-4 overflow-hidden px-4 py-5 sm:px-6 lg:px-8">
<header className="shrink-0">...</header>
<div className="min-h-0 flex-1 overflow-y-auto">
{/* your content */}
</div>
</main>Do not use min-h-screen inside (app)/ routes. That breaks the locked shell.
When a view has a sidebar + main editor (like the quiz preview), the sidebar and editor each need their own scroll:
<section className="grid min-h-0 flex-1 overflow-hidden lg:grid-cols-[260px_1fr]">
<aside className="flex min-h-0 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto">...</div>
</aside>
<div className="overflow-y-auto">...</div> {/* editor scrolls independently */}
</section>Do not use flex-1 on textareas or labels inside a scrollable editor panel — that fights the scroll and clips content. Give textareas natural height with rows={N} and let the parent scroll.
| File | Exports |
|---|---|
button.tsx |
Button, getButtonClassName() |
badge.tsx |
Badge — variants: accent, outline, neutral |
card.tsx |
Card, CardHeader, CardTitle, CardDescription, CardContent |
input.tsx |
Input, Textarea, Select |
page-header.tsx |
PageHeader — eyebrow/title/description |
scaffold-page.tsx |
ScaffoldPage — placeholder for incomplete features |
empty-state.tsx |
EmptyState |
separator.tsx |
Separator |
Use these before reaching for anything external. Do not add Radix primitives or full shadcn components — the radix-ui package is installed but its primitives are not imported in app code.
| Library | Purpose |
|---|---|
sonner |
Toasts — import { toast } from "sonner" |
lucide-react |
Icons — use Loader2 animate-spin for loading |
react-markdown + remark-gfm + remark-math + rehype-katex |
Markdown with math — reuse the MarkdownText component pattern from quizzes/flashcards |
zod v4 |
Validation — safeParse on API bodies |
drizzle-orm |
DB ORM |
better-auth |
Auth — lib/auth.ts, lib/auth-client.ts |
clsx / class-variance-authority |
Installed but use cx() from lib/utils.ts for class merging |
tailwind-merge and cva are in package.json but unused in app code. Do not introduce them.
lib/utils.ts cx() is filter/join only — it does not merge conflicting Tailwind classes like tailwind-merge does. If two classes conflict (e.g. p-4 p-6), the last one wins by CSS source order, not argument order. Account for this manually.
- Between features: use
<Link>and real routes underapp/(app)/. - Within a feature workspace: use a local string-union view state. Never push view state into the URL for in-feature steps.
type View = "library" | "create" | "preview" | "take" | "results";
const [view, setView] = useState<View>("library");This is the pattern in quizzes, flashcards, and notes. Do not use useRouter().push() for within-feature navigation.
Notes uses ?new=1 and ?classId=... to trigger remounts via a key prop on the workspace — useful when you need a clean state without a full route change.
Nav items live in components/shell/navigation.ts. Add new top-level routes there. Study sub-pages go in studyNavItems.
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });const parsed = mySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
// Do NOT return parsed.error.issues — that leaks implementation details
}Always { error: string }. Optionally { logs: string[] } for diagnostic streams (parse-test only). Never an array at the top level.
Almost every API route sets export const runtime = "nodejs". Match this for routes that use DB or AI.
Server Component pages that should not be statically cached must call await connection() from next/server at the top. See quizzes, flashcards, notes pages.
There is no global store. No React Context for app data. No TanStack Query.
- Local
useStatein large client workspaces. - View state machines with string union types (see section 4).
useTransitionfor post-mutation router refreshes.- Temp IDs (
temp-${uuid}) in notes — never call DELETE/PATCH whileisTempNote(id).
Config is in app/globals.css via @import "tailwindcss" + @theme inline. Base font size is 20px on <html> — all rem values are scaled up.
Surfaces: bg-background bg-surface bg-surface-muted bg-surface-elevated
Text: text-foreground text-muted-foreground
Borders: border-border border-border-strong
Accent: bg-accent bg-accent-soft text-accent
Danger: bg-danger-soft text-danger border-red-200 dark:border-red-950/70
Radius: rounded-[var(--radius-xl)]
Shadow: shadow-[var(--shadow-card)]
Applied via .dark class on <html> by applyTheme(). Both :root and html.dark define CSS vars. Always test both.
Never add explanatory page headers describing what a feature does. Users know what Quizzes and Flashcards are. Headers like "Focused quiz library, generation queue, and question editor" add zero value and visual noise.
The only text above the main content area should be:
- Breadcrumb/back navigation (e.g. "My Quizzes" ghost button when a sub-view is open)
- Functional state labels that change with the view (e.g. the step indicator pills in create flow)
When a feature needs explanation, that belongs in onboarding — not on the feature page itself.
No marketing copy inside the app. Descriptions like "Focused deck library, generation queue, and card editor" are not helpful to an active user. Remove them if you see them.
Extract and reuse components. Do not duplicate markup. If two features (e.g. flashcard library and quiz library) have the same visual structure, they must use the same component or the same CSS pattern — not two independently-written blocks that drift apart over time.
- If you write the same element twice, extract a component. The threshold is low: a stat pill, a section header with a count, a card action row — all worth extracting.
- Small local helpers are fine. A
StatPill,StepPill, orEmptyLibrarycomponent that lives near its usage is better than inline-repeated markup. - Prefer shared
components/ui/for anything used across two or more features. If you find yourself defining the same helper inquizzes-client.tsxandflashcards-client.tsx, move it tocomponents/ui/. - Check what exists before building.
components/ui/hasBadge,Button,Card,Input,Textarea,Select,EmptyState,ScaffoldPage,PageHeader,Separator. Use them.
Any view that shows a collection of items (quizzes library, flashcards library, notes list, etc.) must follow the same pattern:
<section className="flex h-full min-h-0 flex-col gap-5">
{/* stats + primary action — shrink-0 */}
<div className="flex shrink-0 flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="flex flex-wrap gap-2">
<StatPill>N items</StatPill>
</div>
<Button leadingIcon={<Plus />}>Create ...</Button>
</div>
{/* empty state */}
<div className="flex min-h-0 flex-1 items-center justify-center rounded-[var(--radius-xl)] border border-dashed border-border bg-surface/70 p-8">
...
</div>
{/* OR: scrollable grid */}
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto pr-1 ...cols">
{items.map(...)}
</div>
</section>Do not reinvent this layout per-feature.
Defining a component inside another component creates a new type on every render — React remounts it every time, losing state. Always define components at module scope and pass data as props.
// WRONG — new type every render, state lost
function Parent() {
const Child = () => <div>...</div>; // ← never do this
return <Child />;
}
// RIGHT
function Child({ value }: { value: string }) {
return <div>{value}</div>;
}
function Parent({ value }: { value: string }) {
return <Child value={value} />;
}These rules come from production React at scale. Violating them causes bugs that are hard to trace.
- Derive state during render, not in effects. If a value can be computed from existing state/props, do it inline — no
useState+useEffectto sync. (React docs) - Put interaction logic in event handlers, not effects. Side effects triggered by a button click belong in the click handler, not a
useEffectthat watches asubmittedboolean. - Use functional
setStateupdates. When new state depends on old state, use the updater form:setItems(curr => [...curr, newItem]). This avoids stale closures and keeps callbacks stable. useReffor transient values. If a value changes frequently and doesn't need to trigger a re-render (mouse position, interval ID, animation frame), useuseRef, notuseState.
- Parallel-fetch independent data.
await A; await B;when A and B are independent is a 2× latency hit. UsePromise.all([A, B])or start both promises before awaiting. await connection()on dynamic pages. Required in Next.js 16 for pages that must not be statically cached (notes, quizzes, flashcards pages already do this — keep it).
- Avoid
&&with numbers.{count && <Badge>}renders0when count is 0. Use{count > 0 ? <Badge> : null}instead. useTransitionover manual loading state.const [isPending, startTransition] = useTransition()gives you a built-in pending flag with correct error-reset behavior. Prefer it overconst [isSaving, setIsSaving] = useState(false)when the operation is navigation-like.toSorted()notsort()..sort()mutates the array — breaks React's immutability model. Use.toSorted()(all modern browsers, Node 20+).
- Direct imports from
lucide-reactare fine — Next.js 16 usesoptimizePackageImportsto tree-shake them automatically. No need for deep import paths. - Dynamic import heavy components. Editor.js, math renderers, and other large libraries should be
next/dynamicwithssr: falseif not needed on initial paint.
-
Zod v4
ZodError.messageis JSON — if you doerror.messagein a catch where the error might be a ZodError, you will leak a JSON array to the client. Always catch parse errors specifically and throw a plain string. -
Embeddings are required for AI features — flashcards and quizzes filter notes by
hasEmbedding. If a user selects notes without embeddings, the API returns 400. UI must disable those notes. -
/api/chathas no auth guard — do not expose sensitive data through it without adding session checks. -
Parse-test is feature-flagged —
isParseTestEnabled()checksENABLE_PARSE_TESTenv var; off in production by default. -
Auth schema and Drizzle schema are linked —
pnpm auth:generaterewriteslib/db/schema.ts. Coordinate with migrations. -
radix-uiis listed in deps but not imported — do not use@radix-ui/react-*primitives directly in new components. -
Many study routes are scaffolds —
/resources, most/study/*pages useScaffoldPageand are not implemented. Don't assume they have real functionality. -
Note editor is Editor.js — complex, debounced, with custom blocks. Hundreds of CSS lines in
globals.css. Do not touch it without readingcomponents/note-editor/. -
Quiz storage guard —
hasQuizStorage()returns false if DB migrations haven't run; quiz APIs return 503. The page handles it gracefully — keep that check when adding quiz API routes. -
No shared form library — no react-hook-form. Build forms with plain
<label>,Input,Textarea,Select, manual state, and Zod validation on the API side. -
hooks/alias exists but the folder is empty — no shared hooks yet. Add new shared hooks there if needed. -
Math rendering — reuse the
MarkdownTextcomponent pattern (ReactMarkdown + remark-gfm + remark-math + rehype-katex) for any user-facing content that may contain LaTeX. Do not add a separate math renderer.