skip to main content
skills.nk

Product Stack

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
---
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.

// 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.

// 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.

// 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:

  • 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.

// 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 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.

// 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 casePattern
Form submission from your appServer Action
Simple create/update/delete from a dialogServer Action
External API consumed by mobile app or third partyAPI Route Handler
Webhook endpointAPI Route Handler
Cacheable GET endpointAPI Route Handler
Complex multi-step mutation with streamingAPI 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
  • 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

// 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 caseDrizzle columnExample
String ID (auth, custom)text("id").primaryKey()User IDs, session tokens
Auto UUIDuuid("id").primaryKey().defaultRandom()Most domain tables
Foreign key.references(() => table.id, { onDelete: "cascade" })Ownership
EnumpgEnum("name", [...]) then use as column typeStatus fields
JSON with typesjsonb("field").$type<T>()Settings, arrays, metadata
Counterinteger("field").notNull().default(0)View counts, metrics
Flagboolean("field").notNull().default(false)Feature toggles
Timestamptimestamp("field").notNull().defaultNow()createdAt, updatedAt
Constrained textvarchar("field", { length: 2 })Country codes, short codes
Decimalnumeric("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<T>() 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<typeof schema>, 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