--- name: product-stack description: Full-stack Next.js architecture for product engineers. A strict 9-layer pattern covering database schemas (Drizzle), API routes, Axios services, React Query hooks, Zod validation, and shadcn/ui components. Every new feature follows the same flow so the codebase stays consistent as it scales. --- # Product Stack A full-stack Next.js architecture for shipping CRUD-heavy applications fast without sacrificing consistency. Built around Next.js App Router, Drizzle ORM, Axios, React Query, Zod, and shadcn/ui. The core idea: every feature follows the same 9-layer flow. Database to UI, no shortcuts. **Backend flow:** DB Schema -> Route Handlers -> Server Services -> Response Helpers **Frontend flow:** Endpoints -> Services -> Hooks -> Components --- ## Project Structure ``` src/ ├── app/ │ ├── (marketing)/ # Public pages │ ├── dashboard/ # Protected pages │ ├── api/ # API route handlers │ │ └── {resource}/ │ │ ├── route.ts # Collection: GET (list), POST (create) │ │ └── [id]/route.ts # Item: GET, PUT, DELETE │ ├── layout.tsx │ └── globals.css │ ├── config/ │ ├── axios.ts # Axios instance + interceptors │ ├── api-endpoints.ts # All API paths + React Query keys │ ├── constants.ts # Enums, storage keys, feature flags │ └── query-client.ts # TanStack Query client defaults │ ├── services/ │ ├── {resource}.service.ts # Frontend: Axios calls to API routes │ └── server/ # Server-only: called from route handlers │ └── {service}.ts │ ├── hooks/ │ └── use-{resource}.ts # React Query hooks wrapping services │ ├── schemas/ │ └── {resource}.ts # Zod validation + inferred types │ ├── types/ │ └── {resource}.ts # TypeScript interfaces │ ├── components/ │ ├── ui/ # shadcn/ui primitives (never hand-edit) │ ├── loaders/ # Skeletons, spinners │ ├── dialogs/ # All modal/dialog components │ └── {feature}/ # Feature-scoped components │ ├── providers/ │ ├── root-provider.tsx # Composes all providers │ ├── theme-provider.tsx │ └── query-provider.tsx # QueryClientProvider setup │ ├── db/ │ ├── index.ts # Drizzle instance + connection │ ├── schema/ │ │ ├── index.ts # Barrel export │ │ ├── auth.ts # User, session, account tables │ │ └── {resource}.ts # Domain tables with enums + indexes │ └── migrations/ # Generated by drizzle-kit │ ├── lib/ │ ├── utils.ts # cn() and shared formatters │ ├── response/ │ │ └── server-response.ts # successResponse, errorResponse, Errors.* │ └── middleware/ │ └── api-middleware.ts # protectedApi, adminApi wrappers │ ├── drizzle.config.ts # Drizzle Kit configuration (project root) └── auth.ts # Auth configuration ``` --- ## Layer 1: API Endpoints All API paths live in one file. No hardcoded strings in services or components. ```typescript // config/api-endpoints.ts export const API_ENDPOINTS = { AUTH: { SIGN_IN: "/api/auth/sign-in", SIGN_OUT: "/api/auth/sign-out", SESSION: "/api/auth/session", }, // Static paths for collection endpoints // Function paths for item endpoints with dynamic IDs PROJECTS: { LIST: "/api/projects", CREATE: "/api/projects", GET: (id: string) => `/api/projects/${id}`, UPDATE: (id: string) => `/api/projects/${id}`, DELETE: (id: string) => `/api/projects/${id}`, }, }; // React Query key factory - colocated with endpoints export const QUERY_KEYS = { PROJECTS: ["projects"], PROJECT: (id: string) => ["projects", id], }; ``` **Rules:** - Group endpoints by resource with comments - Static paths for collections (`LIST`, `CREATE`) - Function paths for items (`GET`, `UPDATE`, `DELETE`) that take an `id` parameter - `QUERY_KEYS` mirror the endpoint structure for cache management - Never construct API paths outside this file --- ## Layer 2: Axios Instance One configured axios instance for the entire app. Auth and error handling happen in interceptors, not in individual service calls. ```typescript // config/axios.ts import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; export const api = axios.create({ baseURL: BASE_URL, timeout: 30000, headers: { "Content-Type": "application/json" }, withCredentials: true, }); // Add auth token to every request api.interceptors.request.use((config: InternalAxiosRequestConfig) => { if (typeof window !== "undefined") { const token = localStorage.getItem("auth_token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } } return config; }); // Handle 401 globally api.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response?.status === 401 && typeof window !== "undefined") { window.location.href = "/sign-in"; } return Promise.reject(error); } ); export default api; ``` **Rules:** - One axios instance, one file - Request interceptor adds auth token from localStorage - Response interceptor handles 401 redirect globally - `withCredentials: true` for cookie-based auth - Services import `api` from this file, never create their own instances --- ## Layer 3: Zod Schemas Schemas define validation AND generate TypeScript types. One file per resource. ```typescript // schemas/project.ts import { z } from "zod"; export const createProjectSchema = z.object({ name: z.string().min(1, "Name is required").max(100), description: z.string().max(500).optional(), status: z.enum(["active", "archived", "draft"]).default("draft"), url: z .string() .transform((val) => val.replace(/\/+$/, "").trim()) .pipe(z.string().url("Enter a valid URL")) .optional(), }); // Update schema: same fields but all optional export const updateProjectSchema = createProjectSchema.partial(); // Infer types from schemas - never define these manually export type CreateProjectInput = z.infer; export type UpdateProjectInput = z.infer; ``` **Rules:** - Use `z.transform()` for data normalization (trim, lowercase, strip protocol) - Use `.pipe()` to chain validators after transforms - Use `.superRefine()` for cross-field validation - Derive update schemas with `.partial()` - Always use `z.infer` for types, never duplicate manually - One schema file per resource in `schemas/` --- ## Layer 4: Frontend Services Services are thin wrappers around axios. One function per API call. No business logic. ```typescript // services/project.service.ts import { API_ENDPOINTS } from "@/config/api-endpoints"; import api from "@/config/axios"; import type { ApiResponse, PaginatedData } from "@/types"; import type { CreateProjectInput, UpdateProjectInput } from "@/schemas/project"; interface Project { id: string; name: string; description?: string; status: string; createdAt: string; } export const projectService = { getAll: async (page = 1, limit = 10) => { const response = await api.get>>( API_ENDPOINTS.PROJECTS.LIST, { params: { page, limit } } ); return response.data; }, getById: async (id: string) => { const response = await api.get>( API_ENDPOINTS.PROJECTS.GET(id) ); return response.data; }, create: async (data: CreateProjectInput) => { const response = await api.post>( API_ENDPOINTS.PROJECTS.CREATE, data ); return response.data; }, update: async (id: string, data: UpdateProjectInput) => { const response = await api.put>( API_ENDPOINTS.PROJECTS.UPDATE(id), data ); return response.data; }, delete: async (id: string) => { const response = await api.delete>( API_ENDPOINTS.PROJECTS.DELETE(id) ); return response.data; }, }; ``` **Rules:** - Export a single object with all methods for the resource - Every method returns `response.data` (unwraps axios response wrapper) - Type the response with `ApiResponse` generic - Use input types from Zod schemas, not manual interfaces - No error handling here, that is the hook's job - No toast, no redirect, no side effects --- ## Layer 5: React Query Hooks Hooks wrap services with React Query. One file per resource. All cache management happens here. ```typescript // hooks/use-projects.ts "use client"; import { QUERY_KEYS } from "@/config/api-endpoints"; import { projectService } from "@/services/project.service"; import type { CreateProjectInput, UpdateProjectInput } from "@/schemas/project"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; // READ - list (paginated) export function useProjects(page = 1, limit = 10, enabled = true) { return useQuery({ queryKey: [...QUERY_KEYS.PROJECTS, { page, limit }], queryFn: () => projectService.getAll(page, limit), enabled, }); } // READ - single export function useProject(id: string) { return useQuery({ queryKey: QUERY_KEYS.PROJECT(id), queryFn: () => projectService.getById(id), enabled: !!id, }); } // CREATE export function useCreateProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: CreateProjectInput) => projectService.create(data), onSuccess: (response) => { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROJECTS }); toast.success(response.message || "Project created"); }, onError: (error: Error & { response?: { data?: { error?: string } } }) => { toast.error(error.response?.data?.error || "Failed to create project"); }, }); } // UPDATE export function useUpdateProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, data }: { id: string; data: UpdateProjectInput }) => projectService.update(id, data), onSuccess: (response, { id }) => { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROJECTS }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROJECT(id) }); toast.success(response.message || "Project updated"); }, onError: (error: Error & { response?: { data?: { error?: string } } }) => { toast.error(error.response?.data?.error || "Failed to update project"); }, }); } // DELETE export function useDeleteProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => projectService.delete(id), onSuccess: (response) => { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROJECTS }); toast.success(response.message || "Project deleted"); }, onError: (error: Error & { response?: { data?: { error?: string } } }) => { toast.error(error.response?.data?.error || "Failed to delete project"); }, }); } ``` **Rules:** - `useQuery` for reads, `useMutation` for writes - `queryKey` always from `QUERY_KEYS` factory - `enabled: !!id` to prevent fetching with empty IDs - `onSuccess`: invalidate related queries + toast - `onError`: extract server error message + toast - Never call services directly from components, always through hooks - TanStack Query v5: use `isPending` not `isLoading` for mutation loading states - Define `QueryClient` outside components (in `query-provider.tsx`), never inside a component body --- ## Layer 6: API Route Handlers Route handlers validate input, call server services, and return formatted responses. ```typescript // app/api/projects/route.ts import { db } from "@/lib/db"; import { projects } from "@/db/schema"; import { protectedApi } from "@/lib/middleware/api-middleware"; import { Errors, getPaginationParams, paginatedResponse, successResponse, } from "@/lib/response/server-response"; import { createProjectSchema } from "@/schemas/project"; import { count, desc, eq } from "drizzle-orm"; import { NextRequest } from "next/server"; // GET /api/projects - List with pagination export const GET = protectedApi(async (request: NextRequest, user) => { const { searchParams } = request.nextUrl; const { page, limit, offset } = getPaginationParams(searchParams); const [[{ value: total }], rows] = await Promise.all([ db.select({ value: count() }).from(projects).where(eq(projects.userId, user.id)), db .select() .from(projects) .where(eq(projects.userId, user.id)) .orderBy(desc(projects.createdAt)) .limit(limit) .offset(offset), ]); return paginatedResponse(rows, total, page, limit); }); // POST /api/projects - Create export const POST = protectedApi(async (request: NextRequest, user) => { const body = await request.json(); const parsed = createProjectSchema.safeParse(body); if (!parsed.success) { return Errors.badRequest(parsed.error.issues[0]?.message || "Invalid input"); } const [project] = await db .insert(projects) .values({ ...parsed.data, userId: user.id }) .returning(); return successResponse(project, "Project created", { status: 201 }); }); ``` ```typescript // app/api/projects/[id]/route.ts import { NextRequest } from "next/server"; type Params = { params: Promise<{ id: string }> }; // GET /api/projects/[id] export const GET = protectedApi( async (_request: NextRequest, user, ctx: Params) => { const { id } = await ctx.params; const [project] = await db .select() .from(projects) .where(eq(projects.id, id)) .limit(1); if (!project) return Errors.notFound("Project not found"); if (project.userId !== user.id) return Errors.forbidden(); return successResponse(project); } ); // PUT /api/projects/[id] export const PUT = protectedApi( async (request: NextRequest, user, ctx: Params) => { const { id } = await ctx.params; const body = await request.json(); const parsed = updateProjectSchema.safeParse(body); if (!parsed.success) { return Errors.badRequest(parsed.error.issues[0]?.message || "Invalid input"); } const [updated] = await db .update(projects) .set({ ...parsed.data, updatedAt: new Date() }) .where(eq(projects.id, id)) .returning(); return successResponse(updated, "Project updated"); } ); // DELETE /api/projects/[id] export const DELETE = protectedApi( async (_request: NextRequest, user, ctx: Params) => { const { id } = await ctx.params; await db.delete(projects).where(eq(projects.id, id)); return successResponse({ id }, "Project deleted"); } ); ``` **Rules:** - Wrap handlers with `protectedApi` or `adminApi` middleware - Always validate body with `schema.safeParse()` before processing - Use `Errors.badRequest()`, `Errors.notFound()`, etc. for error responses - Use `successResponse(data, message, options)` for success responses - Use `paginatedResponse(items, total, page, limit)` for list endpoints - `ctx.params` is a `Promise` in Next.js 15+, always `await` it - Parallel queries with `Promise.all()` when fetching count + rows --- ## Layer 7: Response Helpers Consistent response format across all API routes. ```typescript // lib/response/server-response.ts import { NextResponse } from "next/server"; export function successResponse( data: T, message?: string, options: { status?: number; headers?: Record } = {} ) { return NextResponse.json( { success: true, data, message }, { status: options.status || 200, headers: options.headers } ); } export function paginatedResponse( items: T[], total: number, page: number, limit: number, message?: string ) { const totalPages = Math.ceil(total / limit); return NextResponse.json({ success: true, data: { items, meta: { page, limit, total, totalPages, hasNext: page < totalPages, hasPrev: page > 1 }, }, message, }); } export function errorResponse(error: string, status = 400) { return NextResponse.json({ success: false, error }, { status }); } export const Errors = { badRequest: (msg = "Bad request") => errorResponse(msg, 400), unauthorized: (msg = "Unauthorized") => errorResponse(msg, 401), forbidden: (msg = "Forbidden") => errorResponse(msg, 403), notFound: (msg = "Not found") => errorResponse(msg, 404), conflict: (msg = "Conflict") => errorResponse(msg, 409), internal: (msg = "Internal server error") => errorResponse(msg, 500), }; export function getPaginationParams(searchParams: URLSearchParams) { const page = Math.max(1, parseInt(searchParams.get("page") || "1")); const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "10"))); const offset = (page - 1) * limit; return { page, limit, offset }; } ``` --- ## Layer 8: Shared Types ```typescript // types/index.ts export interface ApiResponse { success: boolean; data: T; error?: string; message?: string; } export interface PaginationMeta { page: number; limit: number; total: number; totalPages: number; hasNext: boolean; hasPrev: boolean; } export interface PaginatedData { items: T[]; meta: PaginationMeta; } ``` --- ## When to Use Server Actions vs API Routes Not every mutation needs the Axios -> Service -> Hook flow. Use **Server Actions** for simple internal mutations. Use **API Route Handlers** when external clients need access. | Use case | Pattern | |----------|---------| | Form submission from your app | Server Action | | Simple create/update/delete from a dialog | Server Action | | External API consumed by mobile app or third party | API Route Handler | | Webhook endpoint | API Route Handler | | Cacheable GET endpoint | API Route Handler | | Complex multi-step mutation with streaming | API Route Handler | ```typescript // app/actions/project.ts "use server"; import { db } from "@/db"; import { projects } from "@/db/schema"; import { createProjectSchema } from "@/schemas/project"; import { revalidatePath } from "next/cache"; export async function createProject(formData: FormData) { const parsed = createProjectSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return { error: parsed.error.issues[0]?.message }; } await db.insert(projects).values({ ...parsed.data, userId: "..." }); revalidatePath("/dashboard"); } ``` **Rule of thumb:** if your frontend is the only consumer, prefer Server Actions. If anything else calls it, use an API route. --- ## Server Components and Data Fetching Pages and layouts are Server Components by default. Fetch data where you render it, not in a parent that passes props down. ```typescript // app/dashboard/page.tsx (Server Component - no "use client") import { db } from "@/db"; import { projects } from "@/db/schema"; import { eq } from "drizzle-orm"; import { ProjectList } from "@/components/projects/project-list"; export default async function DashboardPage() { const userProjects = await db .select() .from(projects) .where(eq(projects.userId, "...")) .orderBy(projects.createdAt); // Pass server-fetched data to client component as props return ; } ``` **Rules:** - Default to Server Components. Only add `"use client"` when you need interactivity (state, effects, event handlers) - Fetch data directly in Server Components using Drizzle, no need for API routes or services - Wrap slow data fetches in `` so fast parts render immediately - `params` and `searchParams` are Promises in Next.js 15+, always `await` them - Keep the `"use client"` boundary as close to the leaf as possible --- ## Layer 9: Database Schema (Drizzle ORM) Schemas live in `src/db/`. Use PostgreSQL with Drizzle ORM. One schema file per domain, barrel-exported from an index file. ### Structure ``` src/db/ ├── index.ts # Connection + drizzle instance ├── schema/ │ ├── index.ts # Barrel export of all tables + enums │ ├── auth.ts # User, session, account tables │ └── {resource}.ts # Domain-specific tables └── migrations/ # Generated by drizzle-kit ``` ### Connection Setup ```typescript // db/index.ts import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; const connectionString = process.env.DATABASE_URL!; const client = postgres(connectionString, { max: 5, idle_timeout: 20, connect_timeout: 10, }); export const db = drizzle(client, { schema }); ``` ### Drizzle Config ```typescript // drizzle.config.ts (project root) import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", schema: "./src/db/schema/index.ts", out: "./src/db/migrations", dbCredentials: { url: process.env.DATABASE_URL!, }, }); ``` ### Reusable Timestamp Columns Extract common columns and spread them into every table definition. ```typescript // db/schema/columns.ts import { timestamp } from "drizzle-orm/pg-core"; export const timestamps = { createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }; ``` ### Table Definitions ```typescript // db/schema/projects.ts import { pgTable, pgEnum, text, uuid, integer, boolean, timestamp, jsonb, index, uniqueIndex, unique } from "drizzle-orm/pg-core"; import { timestamps } from "./columns"; import { users } from "./auth"; // Enums - define before tables that reference them export const projectStatusEnum = pgEnum("project_status", [ "draft", "active", "archived", ]); // Derive TypeScript union from enum export type ProjectStatus = (typeof projectStatusEnum.enumValues)[number]; // Table definition export const projects = pgTable("projects", { // Primary key: uuid with auto-generation id: uuid("id").primaryKey().defaultRandom(), // Foreign key with cascade delete userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), // Required text fields name: text("name").notNull(), description: text("description"), // Enum column with default status: projectStatusEnum("status").notNull().default("draft"), // Boolean with default isPublic: boolean("is_public").notNull().default(false), // Integer for counters viewCount: integer("view_count").notNull().default(0), // Type-safe JSONB settings: jsonb("settings").$type<{ theme: string; notifications: boolean; }>(), tags: jsonb("tags").$type().default([]), // Timestamps - spread the reusable columns ...timestamps, }, (table) => [ // Indexes - third argument to pgTable index("idx_projects_user").on(table.userId), index("idx_projects_status").on(table.userId, table.status), uniqueIndex("idx_projects_user_name").on(table.userId, table.name), ]); // Infer types for select and insert export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; ``` ### Barrel Export ```typescript // db/schema/index.ts export * from "./auth"; export * from "./projects"; // add new schema files here ``` ### Column Type Reference | Use case | Drizzle column | Example | |----------|---------------|---------| | String ID (auth, custom) | `text("id").primaryKey()` | User IDs, session tokens | | Auto UUID | `uuid("id").primaryKey().defaultRandom()` | Most domain tables | | Foreign key | `.references(() => table.id, { onDelete: "cascade" })` | Ownership | | Enum | `pgEnum("name", [...])` then use as column type | Status fields | | JSON with types | `jsonb("field").$type()` | Settings, arrays, metadata | | Counter | `integer("field").notNull().default(0)` | View counts, metrics | | Flag | `boolean("field").notNull().default(false)` | Feature toggles | | Timestamp | `timestamp("field").notNull().defaultNow()` | createdAt, updatedAt | | Constrained text | `varchar("field", { length: 2 })` | Country codes, short codes | | Decimal | `numeric("field", { precision: 14, scale: 4 })` | Financial values | **Rules:** - Every table gets `createdAt` and `updatedAt` via the shared `timestamps` spread - Foreign keys always specify `onDelete` behavior (`cascade` or `set null`) - **Index every foreign key column.** Drizzle does NOT auto-index FKs. Without explicit indexes, joins and lookups on FK columns scan the full table - Use `$type()` on JSONB columns for type safety - Derive TypeScript types with `$inferSelect` and `$inferInsert`, never define manually - Derive enum union types with `(typeof myEnum.enumValues)[number]` - Index naming convention: `idx_{table}_{columns}` - Use composite indexes for queries that filter on multiple columns - Commit migration files to git. Never edit generated SQL manually - Run `pnpm drizzle-kit generate` after schema changes, then `pnpm drizzle-kit migrate` --- ## Adding a New Feature Checklist When you need to add a new resource (e.g. "tasks"), follow this exact order: 1. **DB Schema** - Define table in `db/schema/task.ts` with enums, indexes, and `$inferSelect`/`$inferInsert` types. Export from `db/schema/index.ts`. Run `drizzle-kit generate` + `drizzle-kit migrate` 2. **Endpoints** - Add `TASKS` to `API_ENDPOINTS` and `QUERY_KEYS` in `config/api-endpoints.ts` 3. **Zod Schema** - Create `schemas/task.ts` with request/form validation + inferred input types 4. **Types** - Add interfaces to `types/task.ts` if needed beyond Zod and Drizzle inference 5. **Route** - Create `app/api/tasks/route.ts` (GET list, POST create) and `app/api/tasks/[id]/route.ts` (GET, PUT, DELETE) 6. **Service** - Create `services/task.service.ts` with CRUD methods using axios 7. **Hook** - Create `hooks/use-tasks.ts` with useQuery/useMutation wrapping the service 8. **Components** - Build UI in `components/tasks/` using the hook 9. **Loaders** - Add skeleton in `components/loaders/` for the new views **Never skip a layer.** Components never call services directly. Services never show toasts. Hooks never construct URLs. Route handlers never return raw `NextResponse.json()` without the response helpers. --- ## Tech Stack - **Next.js 15+** (App Router, Server Components, Server Actions) - framework - **TypeScript** - strict mode - **PostgreSQL** + **Drizzle ORM** - database + type-safe queries - **shadcn/ui** - UI components (never hand-edit `components/ui/`) - **TanStack React Query v5** - client-side server state management - **Axios** - HTTP client with interceptors (for client -> API route calls) - **Zod** - schema validation + type inference - **Sonner** - toast notifications --- ## Common Mistakes 1. **Hardcoded API paths** - Always use `API_ENDPOINTS`, never `/api/projects` as a string in services 2. **Calling services from components** - Use hooks. Components should only know about hooks 3. **Manual TypeScript types for form data** - Use `z.infer`, never duplicate types 4. **Toast in services** - Services are data-only. Toast belongs in hook `onSuccess`/`onError` 5. **Raw NextResponse.json in routes** - Use `successResponse()`, `Errors.*()`, `paginatedResponse()` 6. **Missing query invalidation** - Every mutation must invalidate affected query keys 7. **Fetching with empty ID** - Always use `enabled: !!id` on single-item queries 8. **Business logic in components** - Put it in server services or route handlers 9. **Forgetting `.safeParse()`** - Always validate request body in route handlers before processing 10. **Creating axios instances per service** - One instance in `config/axios.ts`, import everywhere 11. **Missing FK indexes** - Drizzle does not auto-index foreign keys. Add `index()` for every FK column or joins will full-scan 12. **Using API routes for simple internal mutations** - Use Server Actions when the frontend is the only consumer 13. **`"use client"` too high in the tree** - Keep it as close to the leaf as possible. Server Components reduce client JS by up to 70% 14. **Forgetting `await` on `params`** - In Next.js 15+, `params` and `searchParams` in page/route components are Promises 15. **Using `isLoading` from mutations** - TanStack Query v5 renamed it to `isPending`