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.
install
curl -fsSL https://skills.nabinkhair.com.np/skills/product-stack/llms.txt -o product-stack.md 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.
// 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 anidparameter QUERY_KEYSmirror 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.
// 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: truefor cookie-based auth- Services import
apifrom this file, never create their own instances
Layer 3: Zod Schemas
Schemas define validation AND generate TypeScript types. One file per resource.
// 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<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
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<typeof schema>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.
// 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<ApiResponse<PaginatedData<Project>>>(
API_ENDPOINTS.PROJECTS.LIST,
{ params: { page, limit } }
);
return response.data;
},
getById: async (id: string) => {
const response = await api.get<ApiResponse<Project>>(
API_ENDPOINTS.PROJECTS.GET(id)
);
return response.data;
},
create: async (data: CreateProjectInput) => {
const response = await api.post<ApiResponse<Project>>(
API_ENDPOINTS.PROJECTS.CREATE,
data
);
return response.data;
},
update: async (id: string, data: UpdateProjectInput) => {
const response = await api.put<ApiResponse<Project>>(
API_ENDPOINTS.PROJECTS.UPDATE(id),
data
);
return response.data;
},
delete: async (id: string) => {
const response = await api.delete<ApiResponse<{ id: string }>>(
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<T>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.
// 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:
useQueryfor reads,useMutationfor writesqueryKeyalways fromQUERY_KEYSfactoryenabled: !!idto prevent fetching with empty IDsonSuccess: invalidate related queries + toastonError: extract server error message + toast- Never call services directly from components, always through hooks
- TanStack Query v5: use
isPendingnotisLoadingfor mutation loading states - Define
QueryClientoutside components (inquery-provider.tsx), never inside a component body
Layer 6: API Route Handlers
Route handlers validate input, call server services, and return formatted responses.
// 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 });
});
// 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
protectedApioradminApimiddleware - 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.paramsis aPromisein Next.js 15+, alwaysawaitit- Parallel queries with
Promise.all()when fetching count + rows
Layer 7: Response Helpers
Consistent response format across all API routes.
// lib/response/server-response.ts
import { NextResponse } from "next/server";
export function successResponse<T>(
data: T,
message?: string,
options: { status?: number; headers?: Record<string, string> } = {}
) {
return NextResponse.json(
{ success: true, data, message },
{ status: options.status || 200, headers: options.headers }
);
}
export function paginatedResponse<T>(
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
// types/index.ts
export interface ApiResponse<T = unknown> {
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<T> {
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 |
// 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.
// 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 <ProjectList initialData={userProjects} />;
}
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
<Suspense>so fast parts render immediately paramsandsearchParamsare Promises in Next.js 15+, alwaysawaitthem- 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
// 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
// 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.
// 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
// 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<string[]>().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
// 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<T>() | 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
createdAtandupdatedAtvia the sharedtimestampsspread - Foreign keys always specify
onDeletebehavior (cascadeorset 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<T>()on JSONB columns for type safety - Derive TypeScript types with
$inferSelectand$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 generateafter schema changes, thenpnpm drizzle-kit migrate
Adding a New Feature Checklist
When you need to add a new resource (e.g. “tasks”), follow this exact order:
- DB Schema - Define table in
db/schema/task.tswith enums, indexes, and$inferSelect/$inferInserttypes. Export fromdb/schema/index.ts. Rundrizzle-kit generate+drizzle-kit migrate - Endpoints - Add
TASKStoAPI_ENDPOINTSandQUERY_KEYSinconfig/api-endpoints.ts - Zod Schema - Create
schemas/task.tswith request/form validation + inferred input types - Types - Add interfaces to
types/task.tsif needed beyond Zod and Drizzle inference - Route - Create
app/api/tasks/route.ts(GET list, POST create) andapp/api/tasks/[id]/route.ts(GET, PUT, DELETE) - Service - Create
services/task.service.tswith CRUD methods using axios - Hook - Create
hooks/use-tasks.tswith useQuery/useMutation wrapping the service - Components - Build UI in
components/tasks/using the hook - 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
- Hardcoded API paths - Always use
API_ENDPOINTS, never/api/projectsas a string in services - Calling services from components - Use hooks. Components should only know about hooks
- Manual TypeScript types for form data - Use
z.infer<typeof schema>, never duplicate types - Toast in services - Services are data-only. Toast belongs in hook
onSuccess/onError - Raw NextResponse.json in routes - Use
successResponse(),Errors.*(),paginatedResponse() - Missing query invalidation - Every mutation must invalidate affected query keys
- Fetching with empty ID - Always use
enabled: !!idon single-item queries - Business logic in components - Put it in server services or route handlers
- Forgetting
.safeParse()- Always validate request body in route handlers before processing - Creating axios instances per service - One instance in
config/axios.ts, import everywhere - Missing FK indexes - Drizzle does not auto-index foreign keys. Add
index()for every FK column or joins will full-scan - Using API routes for simple internal mutations - Use Server Actions when the frontend is the only consumer
"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%- Forgetting
awaitonparams- In Next.js 15+,paramsandsearchParamsin page/route components are Promises - Using
isLoadingfrom mutations - TanStack Query v5 renamed it toisPending