Create with Server Actions
Day 4 - ชั่วโมงที่ 3: Create Issues with Server Actions
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงที่สามของ Day 4 ผู้เรียนควรสามารถ:
- เข้าใจว่า Server Actions ใช้แทน mock client submit จาก Day 3 อย่างไร
- สร้าง Server Action สำหรับ create issue ได้
- อ่าน
FormDataฝั่ง server ได้ - validate input ฝั่ง server ได้
- insert issue ลง Supabase ได้
- ใช้
revalidatePath()และredirect()หลังสร้างข้อมูลได้ - ปรับ
IssueFormให้ใช้<form action={...}>ได้
ไฟล์ที่ใช้ในชั่วโมงนี้
สร้างหรือแก้ไฟล์:
src/app/actions.ts
src/lib/issues.ts
src/components/IssueForm.tsx
src/app/issues/new/page.tsx
src/types/issue.tsโครงสร้างเวลา 60 นาที
| เวลา | หัวข้อ | รูปแบบ |
|---|---|---|
| 0-5 นาที | Recap read จาก Supabase | เชื่อมเข้าเนื้อหา |
| 5-15 นาที | จาก Client Submit ไป Server Action | Explain |
| 15-30 นาที | สร้าง validation และ createIssue() | Live coding |
| 30-45 นาที | สร้าง createIssueAction() | Live coding |
| 45-55 นาที | ปรับ IssueForm ให้ใช้ action | ทำทีละขั้นตอน |
| 55-60 นาที | Recap และ security note | สรุป |
Slide 1: Recap ชั่วโมงที่ 2
ตอนนี้เราทำอะไรได้แล้ว
- อ่าน issue list จาก Supabase
- เปิด
/issuesแล้วเห็นข้อมูลจาก database - เปิด
/issues/[id]แล้วเห็น detail
สิ่งที่ยังทำไม่ได้
- submit form แล้วบันทึกลง database
Key Message
ชั่วโมงนี้เราจะเปลี่ยน Create จาก mock state เป็น insert ลง Supabase จริง
Slide 2: จาก Day 3 Client Submit ไป Day 4 Server Action
Day 3
IssueForm onSubmit -> useState -> IssueListDay 4
IssueForm action -> Server Action -> validate -> Supabase insert -> redirectKey Message
เมื่อข้อมูลต้องบันทึกถาวร ให้ logic สำคัญย้ายมาฝั่ง server
Slide 3: Server Action คืออะไร
Server Action
function ฝั่ง server ที่เรียกจาก form หรือ component ได้
ตัวอย่าง concept
<form action={createIssueAction}>
...
</form>เมื่อ submit form:
browser -> server action -> databaseKey Message
Server Action ช่วยให้ form เชื่อมกับ server logic ได้โดยไม่ต้องเขียน API route แยกในกรณีพื้นฐาน
Slide 4: Type สำหรับ Create Input
File
src/types/issue.tsตำแหน่งที่วาง
วาง NewIssueInput ต่อจาก type Issue เดิมใน src/types/issue.ts แล้ว export เพื่อให้ actions.ts และ issues.ts ใช้ร่วมกันได้
Code
export type NewIssueInput = {
reporterName: string;
reporterEmail: string;
title: string;
description: string;
};Key Message
NewIssueInput คือข้อมูลก่อนเข้า database ยังไม่มี id, status, createdAt
Slide 5: Server-side Validation
File
src/app/actions.tsตำแหน่งที่วาง
สร้างไฟล์ src/app/actions.ts ถ้ายังไม่มี แล้ววาง import และ helper parseIssueInput ไว้ด้านบนไฟล์ ก่อน server action หลัก
Code
import type { NewIssueInput } from "@/types/issue";
function parseIssueInput(formData: FormData): NewIssueInput {
return {
reporterName: String(formData.get("reporterName") ?? "").trim(),
reporterEmail: String(formData.get("reporterEmail") ?? "").trim(),
title: String(formData.get("title") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
};
}Slide 6: Validate Required Fields
File
src/app/actions.tsตำแหน่งที่วาง
วาง function validateIssueInput ต่อจาก parseIssueInput และก่อน createIssueAction
Code
function validateIssueInput(input: NewIssueInput): string[] {
const errors: string[] = [];
if (input.reporterName.length < 2) {
errors.push("Reporter name is required");
}
if (!input.reporterEmail.includes("@")) {
errors.push("Reporter email is invalid");
}
if (input.title.length < 5) {
errors.push("Title must be at least 5 characters");
}
if (input.description.length < 10) {
errors.push("Description must be at least 10 characters");
}
return errors;
}Key Message
Day 3 validation เพื่อ UX แต่ Day 4 validation ฝั่ง server คือสิ่งจำเป็นก่อนบันทึก database
Slide 7: เพิ่ม createIssue()
File
src/lib/issues.tsตำแหน่งที่วาง
วาง import NewIssueInput ไว้บนสุดของไฟล์ และวาง function createIssue ต่อจาก getIssueById
Code
import type { NewIssueInput } from "@/types/issue";
export async function createIssue(input: NewIssueInput) {
const supabase = createSupabaseServerClient();
const { error } = await supabase.from("issues").insert({
reporter_name: input.reporterName,
reporter_email: input.reporterEmail,
title: input.title,
description: input.description,
status: "OPEN",
});
if (error) {
throw new Error(`Failed to create issue: ${error.message}`);
}
}Slide 8: สร้าง createIssueAction()
File
src/app/actions.tsตำแหน่งที่วาง
วาง "use server"; เป็นบรรทัดแรกของไฟล์ จากนั้นวาง createIssueAction ต่อจาก helper parseIssueInput และ validateIssueInput
Code
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createIssue } from "@/lib/issues";
export async function createIssueAction(formData: FormData) {
const input = parseIssueInput(formData);
validateIssueInput(input);
await createIssue(input);
revalidatePath("/issues");
redirect("/issues");
}Key Message
หลัง insert สำเร็จ เรา revalidate หน้า list แล้ว redirect ผู้ใช้กลับไปดูรายการ
Slide 9: ปรับ IssueForm
File
src/components/IssueForm.tsxตำแหน่งที่แก้
ใช้ตัวอย่างนี้ปรับ form เดิม โดยเปลี่ยนจาก onSubmit={...} หรือ form เปล่า ให้เป็น <form action={createIssueAction}>; ถ้าไฟล์มี "use client" จาก Day 3 ให้ลบออกเมื่อไม่มี state/event handler แล้ว
Code
import { createIssueAction } from "@/app/actions";
export function IssueForm() {
return (
<form action={createIssueAction} className="mt-6 grid gap-5">
{/* fields */}
<button
type="submit"
className="rounded-md bg-teal-700 px-4 py-3 text-sm font-bold text-white hover:bg-teal-800"
>
ส่งข้อมูล
</button>
</form>
);
}สำคัญ
ถ้า IssueForm ไม่ใช้ useState หรือ event handler แล้ว ไม่ต้องมี "use client"
Slide 10: หน้า /issues/new
File
src/app/issues/new/page.tsxตำแหน่งที่แก้
ใช้ code นี้แทนเนื้อหาทั้งไฟล์ src/app/issues/new/page.tsx จาก placeholder ของ Day 2
Code
import { IssueForm } from "@/components/IssueForm";
export default function NewIssuePage() {
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<section className="rounded-lg border border-slate-200 bg-white p-6">
<h1 className="text-2xl font-bold text-slate-950">แจ้งปัญหาใหม่</h1>
<p className="mt-2 text-sm text-slate-600">
กรอกข้อมูลปัญหาที่ต้องการให้ฝ่าย IT ตรวจสอบ
</p>
<IssueForm />
</section>
</main>
);
}Slide 11: Field name ต้องตรง
Form field
<input id="title" name="title" type="text" required />Server Action
formData.get("title")Key Message
ชื่อ name ที่ตั้งตั้งแต่ Day 1 ยังเป็นตัวเชื่อมสำคัญถึง Day 4
Slide 12: Test Create Flow
ขั้นตอนทดสอบ
- เปิด
/issues/new - กรอก form
- กด submit
- ระบบ redirect ไป
/issues - issue ใหม่แสดงใน list
- refresh หน้า
- issue ยังอยู่
ถ้าไม่ขึ้น
ตรวจ:
- terminal error
- Supabase Table Editor
- RLS insert policy
- env vars
- field
name
Slide 13: Error UX ตอนนี้ยังไม่สมบูรณ์
ตอนนี้ถ้า validation fail
อาจเห็น error page ของ Next.js
ทำไมยังยอมรับได้ในชั่วโมงนี้
เป้าหมายคือเข้าใจ server action + insert ก่อน
ต่อไปควรทำ
- ใช้
useActionState - return field errors
- แสดง error ใกล้ form
Speaker Notes
อธิบายแนวคิด error state แบบสั้น ๆ เพื่อให้เห็นว่ายังมีจุดที่ต้องปรับเมื่อทำระบบจริง
Slide 14: Server Validation vs Database Constraint
Server validation
if (input.title.length < 5) {
throw new Error("Title must be at least 5 characters");
}Database constraint
title text not null check (char_length(title) >= 5)Key Message
ควรมีทั้งสองชั้น เพราะ server ให้ error ที่ควบคุมได้ ส่วน database เป็นด่านสุดท้าย
Slide 15: Create Issue ลง Supabase
ขั้นตอน
- เพิ่ม
NewIssueInput - เพิ่ม
createIssue()ในsrc/lib/issues.ts - สร้าง
src/app/actions.ts - เขียน
parseIssueInput() - เขียน
validateIssueInput() - เขียน
createIssueAction() - ปรับ
IssueFormให้ใช้action={createIssueAction} - ทดสอบสร้าง issue จริง
ผลลัพธ์
submit form แล้วข้อมูลใหม่เข้า Supabase และแสดงใน /issues
Slide 16: โค้ดสุดท้ายของ Create Flow
src/app/actions.ts
Server Action รับ FormData, validate, insert แล้ว redirect:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createIssue } from "@/lib/issues";
export async function createIssueAction(formData: FormData) {
const input = parseIssueInput(formData);
const errors = validateIssueInput(input);
if (errors.length > 0) {
throw new Error(errors.join(", "));
}
await createIssue(input);
revalidatePath("/issues");
redirect("/issues");
}src/components/IssueForm.tsx
form ไม่ต้องใช้ onSubmit แล้ว ให้เรียก server action ผ่าน action:
import { createIssueAction } from "@/app/actions";
export function IssueForm() {
return (
<form action={createIssueAction}>
{/* fields ใช้ name เดิมจาก Day 1 */}
<button type="submit">ส่งข้อมูล</button>
</form>
);
}ภาพรวม
IssueForm
-> createIssueAction(formData)
-> validate ฝั่ง server
-> createIssue(input)
-> Supabase insert
-> revalidatePath("/issues")
-> redirect("/issues")Slide 17: Common Mistakes
ข้อผิดพลาดที่พบบ่อย
- ลืม
"use server" - ใส่
"use client"ใน form ที่ import server action แล้วเจอปัญหา - field
nameไม่ตรงกับformData.get - RLS policy ไม่อนุญาต insert
- ลืม
revalidatePath("/issues") - redirect ก่อน insert สำเร็จ
- ไม่ restart หลังแก้ env
- คิดว่า
requiredใน HTML พอแล้ว
Slide 18: Recap ชั่วโมงที่สามของ Day 4
สิ่งที่ได้เรียน
- Server Action ใช้รับ form submit ฝั่ง server
FormDataใช้ได้ทั้ง client และ server- ต้อง validate ฝั่ง server ก่อน insert
createIssue()insert ลง SupabaserevalidatePath()ทำให้หน้า list ได้ข้อมูลใหม่redirect()พาผู้ใช้กลับไปหน้า list
ต่อไป
เราจะเพิ่ม update status ลง database, พูดเรื่อง delete เป็น concept และ deploy project ไป Vercel
คำศัพท์สำคัญ
| คำศัพท์ | ความหมาย |
|---|---|
| Server Action | function ฝั่ง server ที่ form เรียกได้ |
FormData | object สำหรับอ่านค่าจาก form |
revalidatePath | สั่งให้ Next.js refresh/cache path ที่เกี่ยวข้อง |
redirect | เปลี่ยนหน้าแบบ server-side |
| Server validation | validation ก่อนบันทึกฝั่ง server |
| Database constraint | validation ชั้น database |