2025-08-30T08:51:59.000+00:00
หลายครั้งผมเปิดโปรเจกต์ Next.js ของตัวเองที่เขียนไปแล้ว 3 เดือน แล้วนั่งงงว่า "เอิ่มม... โค้ดตรงนี้มันทำอะไรวะ?" หรือไม่ก็เวลาจะเพิ่มฟีเจอร์ใหม่แล้วไม่รู้ว่าควรไปแก้ไฟล์ไหน แล้วกลัวว่าไปแตะโค้ดที่ไม่เกี่ยวแล้วระบบพัง?
ผมเลยนั่งเขียน Cookbook เล่มนี้ขึ้นมาเพื่อช่วยให้เราสร้างโปรเจกต์ Next.js ที่:
เหมาะสำหรับผู้ที่มีพื้นฐาน Next.js และ React มาก่อนแล้ว ถ้ายังไม่มี แนะนำให้ลองไปศึกษากันมาก่อนน้าา
เรามาเริ่มด้วยเรื่องจริงกันก่อน Next.js มันเจ๋งมาก flexible มาก แต่... มันก็เหมือนกับให้เครื่องมือฟรีๆ แล้วบอกว่า "ไปสร้างบ้านเอาเองเด้อ"
ปัญหาคือถ้าเราไม่มีแบบแปลน ไม่มีแนวคิดที่ชัดเจน สุดท้ายแล้วเราจะได้บ้านที่:
แล้วจะเกิดอะไรขึ้น?
ถ้าเกิดว่าเรามีโครงสร้างโปรเจคที่ชัดเจน เราก็จะ:
เคยไหมที่เปิดโฟลเดอร์โปรเจกต์ Next.js แล้วเจอหน้าตาแบบนี้:
/app
/api
/users
route.ts # รวม GET/POST/PUT/DELETE + logic + utility
/orders
route.ts # รวม API + processing logic + helper functions
/payments
route.ts
/reports
route.ts
/everything-else
route.ts
/login
page.tsx # หน้า login + API call + validation + helper functions
/dashboard
page.tsx # หน้า dashboard + business logic + API call + utility
/users
page.tsx # หน้า users + API call + processing logic + components
/orders
page.tsx # หน้า orders + API call + calculations + inline components
/random-component
page.tsx # component เล็กๆ + utility functions
/lib
utils.ts # รวมทุก helper function เล็กใหญ่
api-calls.ts # ฟังก์ชันเรียก API ทุกตัว (แต่บางหน้าเรียก API แบบ inline ด้วย)
business-logic.ts # logic ทุกตัว แต่หลายครั้งถูก override ใน page.tsxแล้วพอจะแก้อะไรสักอย่าง ต้องมานั่งเดาว่าโค้ดอยู่ไฟล์ไหน Logic อยู่ตรงไหน กว่าจะเจออาจเสียเวลาไป 30 นาที... แล้วพอเจอแล้ว ไฟล์นั้นมันยาว 500 บรรทัด ต้องมานั่งหาต่อว่าบรรทัดไหน 😵💫
หลักคิดง่ายๆ คือ "แยกตามหน้าที่" เหมือนเราจัดบ้าน:
มาดูโครงสร้างที่แนะนำ:
โปรเจกต์-ของ-เรา/
├── src/
│ ├── app/ # 🏠 หน้าบ้าน (UI + Routing)
│ │ ├── students/
│ │ │ ├── page.tsx # หน้าแสดงรายชื่อนักเรียน
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx # หน้าดูรายละเอียดนักเรียน
│ │ │ └── components/
│ │ │ ├── student-table.tsx
│ │ │ └── student-form.tsx
│ │ └── layout.tsx
│ │
│ ├── features/ # 🧠 สมอง (Business Logic)
│ │ └── student/
│ │ ├── student.actions.ts # รับคำสั่งจากหน้าเว็บ
│ │ ├── student.service.ts # ความคิด/กฎเกณฑ์
│ │ ├── student.dal.ts # คุยกับฐานข้อมูล
│ │ ├── student.types.ts # กำหนดรูปแบบข้อมูล
│ │ └── student.test.ts # ทดสอบการทำงาน
│ │
│ ├── components/ # 🧩 ของเล่น (Global Reusable UI)
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ └── modal.tsx
│ │ ├── editors/
│ │ │ ├── markdown-editor.tsx
│ │ │ └── rich-text-editor.tsx
│ │ └── data-display/
│ │ ├── data-table.tsx
│ │ └── chart.tsx
│ │
│ └── lib/ # 🔧 เครื่องมือ (Utilities)
│ ├── db.ts # ติดต่อฐานข้อมูล
│ ├── errors.ts # จัดการข้อผิดพลาด
│ └── utils.ts # เครื่องมือทั่วไป
│
├── package.json
└── next.config.js/app/features/componentsคำถามสำคัญที่มักเจอคือ "Component นี้ควรเอาไว้ในโฟลเดอร์ไหน?" ให้ลองใช้ Decision Tree นี้:
Component ใหม่ → ใช้แค่หน้าเดียว?
├── YES → app/[route]/components/ (เช่น StudentTable)
└── NO → มี business logic เฉพาะ domain?
├── YES → features/[domain]/components/ (เช่น StudentCard)
└── NO → components/ (เช่น Button, MarkdownEditor)โอเค มาดูตัวอย่างหลายๆกรณีกัน
// app/students/components/student-table.tsx
// ใช้เฉพาะในหน้า students เท่านั้น
export function StudentTable() {
// logic เฉพาะหน้า students แบบ แค่หน้านี้จริงๆ ไม่ได้ไปหน้าอื่นๆเลย
}
เมื่อไหร่ใช้:
components/
├── ui/ # primitive components
│ ├── button.tsx
│ ├── input.tsx
│ ├── dialog.tsx
│ └── card.tsx
├── editors/ # complex components
│ ├── markdown-editor.tsx
│ └── rich-text-editor.tsx
├── data-display/
│ ├── data-table.tsx
│ └── chart.tsx
└── forms/
├── form-builder.tsx
└── file-uploader.tsx
เมื่อไหร่ใช้:
ตัวอย่าง:
// components/ui/button.tsx - primitive
export function Button() { /* basic button */ }
// components/editors/markdown-editor.tsx - complex but reusable
export function MarkdownEditor() { /* full-featured editor */ }
// features/student/components/student-card.tsx
// บัตรประจำตัวนักศึกษา ซึ่งจะใช้ใน domain student หลายหน้า
export function StudentCard({ student }) {
// logic เฉพาะ student domain ซึ่งอาจจะแชร์ไปหน้าอื่นๆในโดเมน student บ้าง เช่น app/student-list app/student-admid อะไรประมาณนี้
}
เมื่อไหร่ใช้:
StudentCard (ใช้ใน list, detail, dashboard)คือ เริ่มจากใกล้ที่สุด → ค่อยขยายออก
app/[route]/components/ ก่อนเสมอfeatures/[domain]/components/components/ui/StudentForm → app/students/components/ (ใช้เฉพาะหน้า students)
Button → components/ui/ (ใช้ทุกที่ในแอป)
StudentCard → features/student/components/ (ใช้ใน student domain)
จำไว้: เริ่มจากใกล้ แล้วค่อยขยาย ตามการใช้งานจริง ไม่ต้องคิดมากตั้งแต่แรกเว้ย
✅ ควรทำ:
student.service.ts ไม่ใช่ services.ts, student-table.tsx ไม่ใช่ table.tsx❌ ไม่ควรทำ:
/app (มันจะวุ่นวาย)/componentsมาดูตัวอย่างการสร้างระบบจัดการนักเรียนง่ายๆ:
// src/features/student/student.types.ts
export interface Student {
id: number;
name: string;
age: number;
email: string;
createdAt: Date;
}
export interface CreateStudentInput {
name: string;
age: number;
email: string;
}
ไฟล์นี้เก็บ "รูปแบบ" ของข้อมูลนักเรียน ทุกไฟล์อื่นจะมาดูที่นี่ว่านักเรียนมีข้อมูลอะไรบ้าง
// src/app/students/page.tsx
import { getStudents } from "@/features/student/student.service";
import { StudentTable } from "./components/student-table";
// หน้าแสดงรายชื่อนักเรียน - ทำงานฝั่ง server
export default async function StudentsPage() {
const students = await getStudents();
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">รายชื่อนักเรียน</h1>
<StudentTable students={students} />
</div>
);
}
ไฟล์นี้เป็น "หน้าเว็บ" ที่แสดงรายชื่อนักเรียน มีหน้าที่แค่เรียกข้อมูลมาแสดง ไม่ได้สนใจว่าข้อมูลมาจากไหน
ลองนึกภาพว่าเราเป็นเจ้าของร้านอาหาร:
DAL ก็เป็นแบบนี้แหละ มีหน้าที่ "คุยกับฐานข้อมูล" เท่านั้น ไม่สนใจว่าใครต้องการข้อมูล หรือจะเอาไปทำอะไร
ก่อนมี DAL - โค้ดจะหน้าตาแบบนี้:
// ใน server component หรือ API route
const user = await prisma.user.findFirst({
where: { email },
include: { profile: true }
});
// ในที่อื่น
const users = await prisma.user.findMany({
orderBy: { name: 'asc' }
});
// อีกที่หนึ่ง
const updateUser = await prisma.user.update({
where: { id },
data: { name, email }
});
ปัญหาคือ:
หลังมี DAL:
// src/features/student/student.dal.ts
import { db } from "@/lib/prisma";
import type { CreateStudentInput, Student } from "./student.types";
export const studentDAL = {
// ค้นหานักเรียนตาม id
async findById(id: number): Promise<Student | null> {
return db.student.findUnique({
where: { id }
});
},
// ดูนักเรียนทั้งหมด
async findAll(): Promise<Student[]> {
return db.student.findMany({
orderBy: { name: 'asc' }
});
},
// สร้างนักเรียนใหม่
async create(data: CreateStudentInput): Promise<Student> {
return db.student.create({
data
});
},
// อัปเดตข้อมูลนักเรียน
async update(id: number, data: Partial<CreateStudentInput>): Promise<Student> {
return db.student.update({
where: { id },
data
});
},
// ลบนักเรียน
async delete(id: number): Promise<void> {
await db.student.delete({
where: { id }
});
},
// ค้นหาตามอีเมล (สำหรับการตรวจสอบ)
async findByEmail(email: string): Promise<Student | null> {
return db.student.findUnique({
where: { email }
});
}
};
// src/features/student/student.dal.ts (ต่อจากด้านบน)
export const studentDAL = {
// ... methods อื่นๆ
// ค้นหานักเรียนที่มีอายุในช่วงที่กำหนด
async findByAgeRange(minAge: number, maxAge: number): Promise<Student[]> {
return db.student.findMany({
where: {
age: {
gte: minAge,
lte: maxAge
}
}
});
},
// นับจำนวนนักเรียนทั้งหมด
async countAll(): Promise<number> {
return db.student.count();
},
// เช็คว่ามีอีเมลนี้ในระบบแล้วมั้ย
async emailExists(email: string, excludeId?: number): Promise<boolean> {
const count = await db.student.count({
where: {
email,
...(excludeId && { id: { not: excludeId } })
}
});
return count > 0;
}
};
สมมติวันนึงเราอยากเปลี่ยนจาก Prisma เป็น Drizzle ก็แค่แก้ใน DAL:
// เปลี่ยนจาก Prisma เป็น Drizzle
import { db } from "@/lib/drizzle"; // ตอนนี้เป็น Drizzle แล้ว
import { studentsTable } from "@/lib/schema";
import { eq } from "drizzle-orm";
export const studentDAL = {
async findById(id: number): Promise<Student | null> {
const results = await db
.select()
.from(studentsTable)
.where(eq(studentsTable.id, id))
.limit(1);
return results[0] || null;
},
// methods อื่นๆ เปลี่ยนไปตาม Drizzle syntax
};
ข้อดี: Service Layer ไม่ต้องเปลี่ยนแม้แต่บรรทัดเดียว!
✅ ควรทำ:
findByEmail, countActive❌ ไม่ควรทำ:
อ่า... ตอนนี้ผมรู้แล้วว่าถ้าอยากเปลี่ยนวิธีการเก็บข้อมูล ก็ไปแก้ที่ DAL ที่เดียว ไม่ต้องไล่แก้ทั้งโปรเจกต์ ทีมสามารถทำงานแยกกันได้ คนนึงดูแล DAL อีกคนทำ service layer ไม่ขัดกัน
ลองนึกถึงการสั่งอาหารที่ร้าน:
พ่อครัว (Service Layer) คือคนที่รู้ว่า:
Service Layer เป็น "สมอง" ของฟีเจอร์ มีหน้าที่:
// src/features/student/student.service.ts
import { studentDAL } from "./student.dal";
import { AppError } from "@/lib/errors";
import { notificationService } from "@/features/notification/notification.service";
import type { CreateStudentInput, Student } from "./student.types";
export const studentService = {
// ดูข้อมูลนักเรียน
async getStudent(id: number, currentUserId: number): Promise<Student> {
// 🔐 เช็คสิทธิ์ก่อน
const canViewStudent = await checkViewPermission(currentUserId, id);
if (!canViewStudent) {
throw new AppError("ไม่มีสิทธิ์ดูข้อมูลนักเรียนคนนี้", "FORBIDDEN");
}
// 📊 ดึงข้อมูล
const student = await studentDAL.findById(id);
if (!student) {
throw new AppError("ไม่พบนักเรียน", "NOT_FOUND");
}
return student;
},
// สร้างนักเรียนใหม่
async createStudent(
input: CreateStudentInput,
currentUserId: number
): Promise<Student> {
// 🔐 เช็คสิทธิ์
if (!await hasCreatePermission(currentUserId)) {
throw new AppError("ไม่มีสิทธิ์สร้างนักเรียน", "FORBIDDEN");
}
// 📋 ตรวจสอบ Business Rules
await validateStudentCreation(input);
// 💾 บันทึกข้อมูル
const newStudent = await studentDAL.create(input);
// 📧 ส่งอีเมลต้อนรับ (Orchestration)
await notificationService.sendWelcomeEmail(
newStudent.email,
newStudent.name
);
// 📝 Log การทำงาน
console.log(`Student created: ${newStudent.id} by user: ${currentUserId}`);
return newStudent;
},
// อัปเดตข้อมูลนักเรียน
async updateStudent(
id: number,
input: Partial<CreateStudentInput>,
currentUserId: number
): Promise<Student> {
// เช็คว่ามีนักเรียนคนนี้มั้ย
const existingStudent = await studentDAL.findById(id);
if (!existingStudent) {
throw new AppError("ไม่พบนักเรียน", "NOT_FOUND");
}
// เช็คสิทธิ์
if (!await canEditStudent(currentUserId, id)) {
throw new AppError("ไม่มีสิทธิ์แก้ไขข้อมูล", "FORBIDDEN");
}
// ถ้ามีการเปลี่ยนอีเมล ต้องเช็คว่าซ้ำมั้ย
if (input.email && input.email !== existingStudent.email) {
const emailExists = await studentDAL.emailExists(input.email, id);
if (emailExists) {
throw new AppError("อีเมลนี้ถูกใช้แล้ว", "DUPLICATE_EMAIL");
}
}
// อัปเดต
const updatedStudent = await studentDAL.update(id, input);
return updatedStudent;
}
};
// 🔧 Helper functions
async function validateStudentCreation(input: CreateStudentInput) {
// ตรวจสอบอายุ
if (input.age < 15) {
throw new AppError("อายุต้องมากกว่า 15 ปี", "INVALID_AGE");
}
if (input.age > 100) {
throw new AppError("อายุไม่ควรเกิน 100 ปี", "INVALID_AGE");
}
// เช็คว่าอีเมลซ้ำมั้ย
const emailExists = await studentDAL.emailExists(input.email);
if (emailExists) {
throw new AppError("อีเมลนี้ถูกใช้แล้ว", "DUPLICATE_EMAIL");
}
// เช็คชื่อไม่ให้เป็นคำไม่สุภาพ
const badWords = ['admin', 'test', 'null'];
if (badWords.some(word => input.name.toLowerCase().includes(word))) {
throw new AppError("ชื่อไม่เหมาะสม", "INAPPROPRIATE_NAME");
}
}
async function checkViewPermission(userId: number, studentId: number): Promise<boolean> {
// ในระบบจริง อาจเช็คจาก database
// เช่น teacher สามารถดูได้เฉพาะนักเรียนในห้องของตัเอง
return true; // simplified for example
}
async function hasCreatePermission(userId: number): Promise<boolean> {
// เช็คว่า user นี้เป็น admin หรือ teacher
return true; // simplified
}
async function canEditStudent(userId: number, studentId: number): Promise<boolean> {
// เช็คสิทธิ์การแก้ไข
return true; // simplified
}
เรื่องความปลอดภัย:
เรื่องความถูกต้อง:
เรื่องการทำงาน:
// src/features/student/student.service.ts (ต่อ)
export const studentService = {
// ... methods อื่นๆ
// ลงทะเบียนเรียน (ซับซ้อนหน่อย)
async enrollCourse(
studentId: number,
courseId: number,
currentUserId: number
): Promise<{ success: boolean; message: string }> {
// 🔍 เช็คข้อมูลพื้นฐาน
const student = await studentDAL.findById(studentId);
if (!student) {
throw new AppError("ไม่พบนักเรียน", "STUDENT_NOT_FOUND");
}
const course = await courseDAL.findById(courseId);
if (!course) {
throw new AppError("ไม่พบคอร์สเรียน", "COURSE_NOT_FOUND");
}
// 📅 เช็คว่าคอร์สยังเปิดรับสมัครอยู่มั้ย
const now = new Date();
if (course.registrationDeadline < now) {
throw new AppError("หมดเวลาลงทะเบียน", "REGISTRATION_CLOSED");
}
// 👥 เช็คที่นั่ง
const currentEnrollments = await enrollmentDAL.countByCourse(courseId);
if (currentEnrollments >= course.maxStudents) {
throw new AppError("คอร์สเต็มแล้ว", "COURSE_FULL");
}
// 📚 เช็คว่าลงทะเบียนซ้ำมั้ย
const existingEnrollment = await enrollmentDAL.findByStudentAndCourse(
studentId,
courseId
);
if (existingEnrollment) {
throw new AppError("ลงทะเบียนคอร์สนี้แล้ว", "ALREADY_ENROLLED");
}
// 💰 เช็ค prerequisite (ถ้ามี)
if (course.prerequisiteIds?.length > 0) {
const completedCourses = await enrollmentDAL.getCompletedCourses(studentId);
const hasPrerequisites = course.prerequisiteIds.every(reqId =>
completedCourses.some(completed => completed.courseId === reqId)
);
if (!hasPrerequisites) {
throw new AppError("ไม่ผ่านเงื่อนไขพื้นฐาน", "PREREQUISITE_NOT_MET");
}
}
// 🎯 ลงทะเบียนจริง
const enrollment = await enrollmentDAL.create({
studentId,
courseId,
enrolledAt: new Date(),
status: 'active'
});
// 📧 ส่งอีเมลยืนยัน
await notificationService.sendEnrollmentConfirmation(
student.email,
course.name,
course.startDate
);
// 📊 อัปเดตสถิติ
await analyticsService.trackEnrollment({
studentId,
courseId,
timestamp: new Date()
});
return {
success: true,
message: `ลงทะเบียน ${course.name} สำเร็จแล้ว!`
};
},
// ดูรายงานสรุป (สำหรับ admin)
async getStudentSummary(currentUserId: number) {
// เช็คว่าเป็น admin
if (!await isAdmin(currentUserId)) {
throw new AppError("ไม่มีสิทธิ์ดูรายงาน", "FORBIDDEN");
}
const [
totalStudents,
activeStudents,
newThisMonth,
topCourses
] = await Promise.all([
studentDAL.countAll(),
studentDAL.countActive(),
studentDAL.countNewThisMonth(),
courseDAL.getTopEnrolledCourses(5)
]);
return {
totalStudents,
activeStudents,
newThisMonth,
topCourses,
generatedAt: new Date()
};
}
};
// src/lib/errors.ts
export class AppError extends Error {
constructor(
message: string,
public code: string = "UNKNOWN_ERROR",
public statusCode: number = 400
) {
super(message);
this.name = "AppError";
}
}
// Error codes ที่ใช้บ่อย
export const ERROR_CODES = {
VALIDATION: "VALIDATION_ERROR",
NOT_FOUND: "NOT_FOUND",
FORBIDDEN: "FORBIDDEN",
DUPLICATE: "DUPLICATE_ENTRY",
BUSINESS_RULE: "BUSINESS_RULE_VIOLATION"
} as const;
// src/features/student/student.service.test.ts
import { studentService } from "./student.service";
import { studentDAL } from "./student.dal";
import { AppError } from "@/lib/errors";
// Mock DAL
jest.mock("./student.dal");
const mockStudentDAL = studentDAL as jest.Mocked<typeof studentDAL>;
describe("Student Service", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("createStudent", () => {
it("should create student successfully", async () => {
// Arrange
const input = {
name: "นาย ทดสอบ",
age: 20,
email: "test@example.com"
};
const expectedStudent = { id: 1, ...input };
mockStudentDAL.emailExists.mockResolvedValue(false);
mockStudentDAL.create.mockResolvedValue(expectedStudent);
// Act
const result = await studentService.createStudent(input, 1);
// Assert
expect(result).toEqual(expectedStudent);
expect(mockStudentDAL.emailExists).toHaveBeenCalledWith(input.email);
expect(mockStudentDAL.create).toHaveBeenCalledWith(input);
});
it("should reject underage student", async () => {
// Arrange
const input = {
name: "เด็กเล็ก",
age: 10,
email: "kid@example.com"
};
// Act & Assert
await expect(
studentService.createStudent(input, 1)
).rejects.toThrow(new AppError("อายุต้องมากกว่า 15 ปี", "INVALID_AGE"));
});
it("should reject duplicate email", async () => {
// Arrange
const input = {
name: "คนใหม่",
age: 20,
email: "duplicate@example.com"
};
mockStudentDAL.emailExists.mockResolvedValue(true);
// Act & Assert
await expect(
studentService.createStudent(input, 1)
).rejects.toThrow(new AppError("อีเมลนี้ถูกใช้แล้ว", "DUPLICATE_EMAIL"));
});
});
});
✅ ควรทำ:
❌ ไม่ควรทำ:
ตอนนี้ผมก็เข้าใจแล้วว่าทำไมบางครั้งเวลาสร้าง user ใหม่มันต้องเช็คหลายอย่าง ปกติคิดว่ามันแค่ save ลง database นี่หว่า ละก็ Service layer แบบนี้ทำให้ทีมสามารถแยก concern ได้ชัดเจน คนหนึ่งดูแล business logic คนหนึ่งดูแล UI ไม่ปะทะกัน อีกอย่าง Service layer ที่มี proper logging และ error handling แบบนี้ช่วยให้ monitor และ debug ง่ายมาก เห็น error code ก็รู้ว่าปัญหาอยู่ตรงไหน
มันคือฟังก์ชันฝั่ง Server ที่เราสามารถเรียกใช้ได้โดยตรงจาก Component ฝั่ง Client (ที่ใส่ "use client") เหมือนเรียกฟังก์ชันธรรมดาๆ เลย ถ้าเทียบกับร้านอาหาร Server Actions เป็นเหมือนพนักงานเสิร์ฟ ที่:
FormData)zod คือดีที่สุด!)revalidatePath) เพื่อให้ข้อมูลหน้าเว็บอัปเดตสมัยก่อน (ซึ่งก็ไม่นานเท่าไหร่) เวลาเราจะส่งข้อมูลจากฟอร์มหน้าเว็บไปหลังบ้าน เราต้องทำอะไรบ้าง?
/api/students)fetch ในฝั่ง Client เพื่อยิงไปที่ API นั้นloading, error, success states ด้วยตัวเองมันเหมือนกับลูกค้า (UI) ต้องเขียนใบสั่งอาหารเอง (สร้าง JSON), เดินไปส่งที่ครัวเอง (เรียก fetch), แล้วก็ยืนรออาหารเอง... วุ่นวายใช่ไหมล่ะ?
Server Actions เข้ามาเปลี่ยนเกมนี้ไปเลยครับ มันคือ "พนักงานเสิร์ฟอัจฉริยะ" ที่ Next.js จัดมาให้
กฎทองของ Server Actions คือ จง "บาง" เข้าไว้ (Keep it thin!) พนักงานเสิร์ฟไม่จำเป็นต้องรู้สูตรทำอาหารฉันใด Server Actions ก็ไม่ควรมี Business Logic ฉันนั้น! หน้าที่ของมันคือ "ตัวกลาง" ที่ดีเท่านั้น
// src/features/student/student.actions.ts
"use server";
import { z } from "zod";
import { studentService } from "./student.service";
import { revalidatePath } from "next/cache";
import { AppError } from "@/lib/errors";
// 📋 Schema สำหรับ validation
const createStudentSchema = z.object({
name: z.string()
.min(1, "กรุณากรอกชื่อ")
.max(100, "ชื่อยาวเกินไป"),
age: z.coerce.number()
.min(15, "อายุต้องมากกว่า 15 ปี")
.max(100, "อายุไม่ควรเกิน 100 ปี"),
email: z.string()
.email("รูปแบบอีเมลไม่ถูกต้อง")
.toLowerCase()
});
// 📤 Action สำหรับสร้างนักเรียนใหม่
export async function createStudentAction(
prevState: any,
formData: FormData
) {
try {
// 🔍 Parse และ validate ข้อมูล
const rawData = {
name: formData.get("name"),
age: formData.get("age"),
email: formData.get("email")
};
const validatedData = createStudentSchema.parse(rawData);
// 🔐 ดึงข้อมูล user ปัจจุบัน (ในโปรเจกต์จริง)
// const session = await getServerSession();
// const currentUserId = session?.user?.id;
const currentUserId = 1; // สำหรับตัวอย่าง
// 🎯 เรียก service layer
const newStudent = await studentService.createStudent(
validatedData,
currentUserId
);
// 🔄 อัปเดต cache
revalidatePath("/students");
// ✅ ส่งผลลัพธ์สำเร็จ
return {
success: true,
message: `สร้างนักเรียน ${newStudent.name} สำเร็จแล้ว!`,
data: newStudent,
errors: null
};
} catch (error) {
console.error("Create student error:", error);
// 🔍 จัดการ validation errors
if (error instanceof z.ZodError) {
return {
success: false,
message: "ข้อมูลไม่ถูกต้อง",
data: null,
errors: error.flatten().fieldErrors
};
}
// 🔍 จัดการ business logic errors
if (error instanceof AppError) {
return {
success: false,
message: error.message,
data: null,
errors: null
};
}
// 🔍 จัดการ unknown errors
return {
success: false,
message: "เกิดข้อผิดพลาดไม่ทราบสาเหตุ",
data: null,
errors: null
};
}
}
// 📝 Action สำหรับอัปเดต
export async function updateStudentAction(
id: number,
prevState: any,
formData: FormData
) {
try {
const rawData = {
name: formData.get("name"),
age: formData.get("age"),
email: formData.get("email")
};
// ใช้ partial schema สำหรับการอัปเดต
const updateSchema = createStudentSchema.partial();
const validatedData = updateSchema.parse(rawData);
// อัปเดตข้อมูล
const currentUserId = 1;
const updatedStudent = await studentService.updateStudent(
id,
validatedData,
currentUserId
);
revalidatePath("/students");
revalidatePath(`/students/${id}`);
return {
success: true,
message: "อัปเดตข้อมูลสำเร็จ",
data: updatedStudent,
errors: null
};
} catch (error) {
// Error handling เหมือนเดิม...
return handleActionError(error);
}
}
// 🗑️ Action สำหรับลบ
export async function deleteStudentAction(id: number) {
try {
const currentUserId = 1;
await studentService.deleteStudent(id, currentUserId);
revalidatePath("/students");
return {
success: true,
message: "ลบนักเรียนสำเร็จ"
};
} catch (error) {
return handleActionError(error);
}
}
// 🔧 Helper function สำหรับจัดการ error
function handleActionError(error: unknown) {
console.error("Action error:", error);
if (error instanceof z.ZodError) {
return {
success: false,
message: "ข้อมูลไม่ถูกต้อง",
errors: error.flatten().fieldErrors
};
}
if (error instanceof AppError) {
return {
success: false,
message: error.message,
errors: null
};
}
return {
success: false,
message: "เกิดข้อผิดพลาดไม่ทราบสาเหตุ",
errors: null
};
}
// src/app/students/components/student-form.tsx
"use client";
import { useFormState } from "react-dom";
import { createStudentAction } from "@/features/student/student.actions";
const initialState = {
success: false,
message: "",
data: null,
errors: null
};
export function StudentForm() {
const [state, formAction] = useFormState(createStudentAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name">ชื่อ-นามสกุล</label>
<input
type="text"
name="name"
id="name"
required
className="w-full p-2 border rounded"
/>
{state.errors?.name && (
<p className="text-red-500 text-sm">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="age">อายุ</label>
<input
type="number"
name="age"
id="age"
required
className="w-full p-2 border rounded"
/>
{state.errors?.age && (
<p className="text-red-500 text-sm">{state.errors.age[0]}</p>
)}
</div>
<div>
<label htmlFor="email">อีเมล</label>
<input
type="email"
name="email"
id="email"
required
className="w-full p-2 border rounded"
/>
{state.errors?.email && (
<p className="text-red-500 text-sm">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
บันทึก
</button>
{/* แสดงผลลัพธ์ */}
{state.message && (
<div className={`p-3 rounded ${
state.success
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}>
{state.message}
</div>
)}
</form>
);
}
// src/features/student/student.actions.ts (ต่อ)
export async function uploadStudentPhotoAction(
studentId: number,
prevState: any,
formData: FormData
) {
try {
const file = formData.get("photo") as File;
if (!file || file.size === 0) {
return {
success: false,
message: "กรุณาเลือกไฟล์รูปภาพ"
};
}
// เช็คขนาดไฟล์ (5MB)
if (file.size > 5 * 1024 * 1024) {
return {
success: false,
message: "ไฟล์รูปภาพใหญ่เกินไป (สูงสุด 5MB)"
};
}
// เช็คประเภทไฟล์
if (!file.type.startsWith("image/")) {
return {
success: false,
message: "กรุณาเลือกไฟล์รูปภาพเท่านั้น"
};
}
// อัปโลดไฟล์ (สามารถใช้ Cloudinary, AWS S3, etc.)
const photoUrl = await uploadFile(file);
// อัปเดตข้อมูลในฐานข้อมูล
await studentService.updatePhoto(studentId, photoUrl, 1);
revalidatePath(`/students/${studentId}`);
return {
success: true,
message: "อัปโลดรูปภาพสำเร็จ",
data: { photoUrl }
};
} catch (error) {
return handleActionError(error);
}
}
export async function bulkUpdateStudentsAction(
studentIds: number[],
updates: Partial<CreateStudentInput>
) {
try {
if (studentIds.length === 0) {
return {
success: false,
message: "กรุณาเลือกนักเรียนอย่างน้อย 1 คน"
};
}
const results = await studentService.bulkUpdate(
studentIds,
updates,
1 // currentUserId
);
revalidatePath("/students");
return {
success: true,
message: `อัปเดตข้อมูลนักเรียน ${results.updated} คน สำเร็จ`,
data: results
};
} catch (error) {
return handleActionError(error);
}
}
// src/app/students/components/student-form-with-loading.tsx
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { createStudentAction } from "@/features/student/student.actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{pending ? "กำลังบันทึก..." : "บันทึก"}
</button>
);
}
export function StudentFormWithLoading() {
const [state, formAction] = useFormState(createStudentAction, {
success: false,
message: "",
errors: null
});
return (
<form action={formAction} className="space-y-4">
{/* form fields... */}
<SubmitButton />
{/* error/success messages... */}
</form>
);
}
✅ ควรทำ:
try/catch คือเพื่อนแท้ของเรา จัดการ ZodError, AppError, และ Error ทั่วไปให้ครบ❌ ไม่ควรทำ:
if/else ซับซ้อนใน Action... คุณกำลังมาผิดทางแล้ว! ย้ายมันไปที่ Service Layer ซะServer Actions ทำให้เราไม่ต้องเขียน API แยก และยังได้ type safety มาด้วย Form handling ง่ายขึ้นเยอะเลย pattern นี้ช่วยให้ทีมเขียน form ได้เร็วขึ้น และ error handling ที่ consistent ทำให้ maintenance ง่าย
เรามีครัว (Backend Logic) ที่สุดยอดแล้ว แต่ถ้าหน้าร้าน (UI) จัดจานไม่สวย จัดโต๊ะช้า ลูกค้าก็หนีหมด! ในโลกของ Next.js การ "จัดจาน" คือการเลือกใช้ Server Components และ Client Components ให้ถูกที่ถูกเวลา
คิดง่ายๆ แบบนี้:
"use client"): เหมือน "หมูกระทะบนโต๊ะ" ที่ลูกค้าต้องลงมือปิ้งย่างเอง มีปฏิสัมพันธ์ (Interaction) เช่น การกดปุ่ม, การกรอกฟอร์ม, การเรียงข้อมูลในตารางกฎทองคำ: "ทุกอย่างเป็น Server Component จนกว่าจะต้องเป็น Client Component"
ลองดูหน้า StudentsPage ของเราอีกครั้ง แต่คราวนี้เราจะใส่ใจเรื่องการ "จัดจาน" มากขึ้น
// src/app/students/page.tsx (🏔️ Server Component)
import { Suspense } from "react";
import { studentService } from "@/features/student/student.service";
import { StudentTable } from "./components/student-table";
import { StudentForm } from "./components/student-form";
import { StudentCount } from "./components/student-count";
// นี่คือ "โต๊ะอาหาร" หลักของเรา
export default async function StudentsPage() {
// การเรียกข้อมูลเกิดขึ้นที่ Server ทั้งหมด!
const students = await studentService.getAllStudents();
return (
<div className="container p-6">
<h1 className="text-3xl font-bold mb-4">ระบบจัดการนักเรียน</h1>
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2">
<h2 className="text-xl font-semibold mb-2">รายชื่อนักเรียน</h2>
{/* ส่วนนี้เป็น "หมูกระทะ" ที่ต้องให้ลูกค้าปิ้งเอง */}
<StudentTable students={students} />
</div>
<div>
<h2 className="text-xl font-semibold mb-2">เพิ่มนักเรียนใหม่</h2>
{/* ฟอร์มก็เป็น "หมูกระทะ" เช่นกัน */}
<StudentForm />
<div className="mt-6">
<h2 className="text-xl font-semibold mb-2">ข้อมูลสรุป</h2>
{/* ส่วนนี้ซับซ้อน อาจจะโหลดช้า เราเลยใช้ Suspense ห่อไว้ */}
<Suspense fallback={<p>กำลังโหลดข้อมูลสรุป...</p>}>
<StudentStats />
</Suspense>
</div>
</div>
</div>
</div>
);
}
// อีกหนึ่ง Server Component ที่ดึงข้อมูลของตัวเอง
async function StudentStats() {
const total = await studentService.countAllStudents();
// สมมติว่ารอ 2 วินาทีเพื่อให้เห็นผลของ Suspense
await new Promise(resolve => setTimeout(resolve, 2000));
return <p>มีนักเรียนในระบบทั้งหมด: {total} คน</p>;
}
แล้ว StudentTable ล่ะ?
// src/app/students/components/student-table.tsx (🍳 Client Component)
"use client";
import { useState, useMemo } from "react";
import type { Student } from "@/features/student/student.types";
export function StudentTable({ students: initialStudents }: { students: Student[] }) {
const [searchTerm, setSearchTerm] = useState("");
const filteredStudents = useMemo(() => {
if (!searchTerm) return initialStudents;
return initialStudents.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [searchTerm, initialStudents]);
return (
<div>
<input
type="text"
placeholder="ค้นหาชื่อ..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-2 border rounded mb-4"
/>
{/* ... โค้ดแสดงตาราง ... */}
<table>
{/* ... */}
<tbody>
{filteredStudents.map(student => (
<tr key={student.id}>
<td>{student.name}</td>
<td>{student.email}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
เห็นอะไรมั้ยครับ?
StudentsPage (Server Component) ทำหน้าที่หนักในการดึงข้อมูลและวางโครงสร้างstudents) ที่ปรุงเสร็จแล้วไปให้ StudentTableStudentTable (Client Component) รับข้อมูลนั้นมา แล้วจัดการแค่ส่วนของ Interaction (การค้นหา) ในฝั่ง Clientนี่คือการแบ่งงานที่สมบูรณ์แบบเลยแหละ เราได้ทั้งความเร็วและ SEO จาก Server Component และได้ Interaction ที่ลื่นไหลจาก Client Component
✅ ควรทำ:
"use client"! ให้แยกเฉพาะส่วนที่มีปุ่ม, ฟอร์ม, หรือ useState/useEffect ออกมาเป็น Component เล็กๆfetch ข้อมูลใน Client Component ถ้าไม่จำเป็นจริงๆ (เช่น การทำ polling)Suspense: สำหรับส่วนที่โหลดข้อมูลนาน เพื่อให้ผู้ใช้เห็นส่วนอื่นของหน้าเว็บไปก่อน ไม่ต้องรอทั้งหน้า❌ ไม่ควรทำ:
"use client" พร่ำเพรื่อ: การทำแบบนี้จะทำให้คุณเสียประโยชน์จาก Server-Side Rendering ไปโดยใช่เหตุเราพูดถึง AppError ใน Service Layer และการ try/catch ใน Server Actions ไปแล้ว แต่ยังไม่จบ... เราต้องมีแผนรับมือ "หายนะ" ที่ผู้ใช้จะได้ไม่เจอหน้าเว็บขาวๆ หรือข้อความ error ที่น่าเกลียด
Next.js มีเครื่องมือให้เรา 2 อย่าง:
error.tsx: ไฟล์นี้จะถูกแสดงผลเมื่อมี Error ที่ ไม่คาดคิด (500 Internal Error) เกิดขึ้นใน Component ลูกๆ ของมัน (ทำงานในฝั่ง Client)not-found.tsx: ไฟล์ที่แสดงผลเมื่อเรียกฟังก์ชัน notFound() หรือเมื่อเข้า URL ที่ไม่มีอยู่จริง (404 Not Found Error)ตัวอย่าง:
// src/app/students/[id]/page.tsx (หน้าดูรายละเอียดนักเรียน)
import { notFound } from "next/navigation";
import { studentService } from "@/features/student/student.service";
export default async function StudentDetailPage({ params }: { params: { id: string } }) {
try {
const student = await studentService.getStudent(Number(params.id), 1); // สมมติ actorId=1
// ... แสดงข้อมูลนักเรียน
} catch (error) {
if (error instanceof AppError && error.code === 'NOT_FOUND') {
notFound(); // << บอก Next.js ให้ไปแสดงหน้า not-found.tsx
}
// ถ้าเป็น error อื่นที่ไม่คาดคิด ให้มันโยนออกไปเพื่อให้ error.tsx ทำงาน
throw error;
}
}
// src/app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="text-center">
<h2 className="text-2xl font-bold">ไม่พบข้อมูล</h2>
<p>ขออภัย เราไม่พบหน้าที่คุณต้องการค้นหา</p>
<Link href="/" className="text-blue-500">กลับสู่หน้าหลัก</Link>
</div>
);
}
// src/app/error.tsx
"use client"; // Error components ต้องเป็น Client Components
export default function Error({ error, reset }: { error: Error; reset: () => void; }) {
useEffect(() => {
// อาจจะส่ง error ไปที่ระบบ monitoring เช่น Sentry
console.error(error);
}, [error]);
return (
<div className="text-center">
<h2 className="text-2xl font-bold">โอ๊ะ! เกิดข้อผิดพลาดบางอย่าง</h2>
<button onClick={() => reset()} className="bg-blue-500 text-white p-2 rounded mt-4">
ลองอีกครั้ง
</button>
</div>
);
}
กฎเหล็กของการรับมือ Error:
AppError สำหรับ Business Logic, ใช้ notFound() สำหรับข้อมูลที่ไม่มี, และปล่อยให้ error.tsx จัดการกับสิ่งที่เหลือเราเขียน Unit Test สำหรับ Service Layer ไปแล้ว ซึ่งสำคัญที่สุด แต่การเทสไม่ได้มีแค่นั้น:
คำแนะนำ: โฟกัสที่ Unit Tests ของ Service Layer ให้ครอบคลุมที่สุด เพราะมันคือสมองของระบบ ถ้าสมองทำงานถูกต้อง ส่วนอื่นๆ ก็มักจะถูกต้องตามไปด้วย
ยินดีด้วยครับผมม คุณได้เดินทางผ่านสูตรลับทั้งหมดใน Cookbook เล่มนี้แล้ว
หัวใจสำคัญไม่ได้อยู่ที่การจำโค้ดได้ทุกบรรทัด แต่อยู่ที่การเข้าใจ "หลักการ" ที่อยู่เบื้องหลัง ได้แก่:
/app (หน้าร้าน) ออกจาก /src/features (ห้องครัว)การวางโครงสร้างที่ดีในตอนแรกอาจจะดูเหมือนเสียเวลา แต่เชื่อผมเถอะครับว่ามันคือการลงทุนที่คุ้มค่าที่สุด มันจะช่วยให้คุณ (ละก็ทีมของคุณด้วย) สร้างโปรเจกต์ Next.js ที่ไม่เพียงแต่ทำงานได้ดีในวันนี้ แต่ยังง่ายต่อการดูแลรักษา ต่อเติม และส่งต่อในวันข้างหน้า
ขอบคุณที่อ่านจนจบครับ